summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.editorconfig8
-rw-r--r--.gitattributes3
-rw-r--r--.gitlab-ci.yml2
-rw-r--r--CMakeLists.txt13
-rw-r--r--man/nheko.1.adoc4
-rw-r--r--resources/langs/nheko_fr.ts86
-rw-r--r--resources/qml/CommunitiesList.qml17
-rw-r--r--resources/qml/Completer.qml5
-rw-r--r--resources/qml/EncryptionIndicator.qml2
-rw-r--r--resources/qml/ForwardCompleter.qml28
-rw-r--r--resources/qml/MatrixText.qml27
-rw-r--r--resources/qml/MessageView.qml412
-rw-r--r--resources/qml/Reactions.qml2
-rw-r--r--resources/qml/ReplyPopup.qml18
-rw-r--r--resources/qml/RoomList.qml13
-rw-r--r--resources/qml/Root.qml25
-rw-r--r--resources/qml/TimelineBubbleMessageStyle.qml334
-rw-r--r--resources/qml/TimelineDefaultMessageStyle.qml327
-rw-r--r--resources/qml/TimelineEvent.qml266
-rw-r--r--resources/qml/TimelineMetadata.qml101
-rw-r--r--resources/qml/TimelineRow.qml349
-rw-r--r--resources/qml/TimelineSectionHeader.qml166
-rw-r--r--resources/qml/TimelineView.qml5
-rw-r--r--resources/qml/TopBar.qml21
-rw-r--r--resources/qml/components/PowerlevelIndicator.qml2
-rw-r--r--resources/qml/delegates/Encrypted.qml33
-rw-r--r--resources/qml/delegates/EncryptionEnabled.qml47
-rw-r--r--resources/qml/delegates/FileMessage.qml36
-rw-r--r--resources/qml/delegates/ImageMessage.qml30
-rw-r--r--resources/qml/delegates/MessageDelegate.qml779
-rw-r--r--resources/qml/delegates/PlayableMediaMessage.qml2
-rw-r--r--resources/qml/delegates/Redacted.qml47
-rw-r--r--resources/qml/delegates/Reply.qml150
-rw-r--r--resources/qml/delegates/TextMessage.qml12
-rw-r--r--resources/qml/dialogs/AliasEditor.qml8
-rw-r--r--resources/qml/ui/TimelineEffects.qml52
-rw-r--r--src/AliasEditModel.cpp4
-rw-r--r--src/Cache.cpp207
-rw-r--r--src/Cache.h3
-rw-r--r--src/Cache_p.h16
-rw-r--r--src/ChatPage.cpp14
-rw-r--r--src/InviteesModel.cpp4
-rw-r--r--src/MxcImageProvider.cpp2
-rw-r--r--src/PowerlevelsEditModels.cpp20
-rw-r--r--src/UserSettingsPage.cpp4
-rw-r--r--src/Utils.cpp2
-rw-r--r--src/encryption/Olm.cpp2
-rw-r--r--src/main.cpp9
-rw-r--r--src/notifications/Manager.cpp2
-rw-r--r--src/notifications/ManagerLinux.cpp4
-rw-r--r--src/timeline/CommunitiesModel.cpp2
-rw-r--r--src/timeline/DelegateChooser.cpp4
-rw-r--r--src/timeline/EventDelegateChooser.cpp360
-rw-r--r--src/timeline/EventDelegateChooser.h276
-rw-r--r--src/timeline/EventStore.cpp6
-rw-r--r--src/timeline/InputBar.cpp2
-rw-r--r--src/timeline/RoomlistModel.cpp6
-rw-r--r--src/timeline/TimelineFilter.cpp22
-rw-r--r--src/timeline/TimelineModel.cpp311
-rw-r--r--src/timeline/TimelineModel.h35
-rw-r--r--src/ui/MxcAnimatedImage.cpp9
-rw-r--r--src/ui/MxcAnimatedImage.h11
-rw-r--r--src/ui/NhekoDropArea.cpp1
-rw-r--r--src/ui/RoomSettings.cpp2
-rw-r--r--src/voip/CallManager.cpp3
65 files changed, 2724 insertions, 2051 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644

index 00000000..65ee036b --- /dev/null +++ b/.editorconfig
@@ -0,0 +1,8 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +max_line_length = 100 diff --git a/.gitattributes b/.gitattributes new file mode 100644
index 00000000..04bb3c2a --- /dev/null +++ b/.gitattributes
@@ -0,0 +1,3 @@ +# Explicitly disable EOL normalization +* -text diff +third_party/** linguist-vendored diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0dab93df..c345883d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml
@@ -106,7 +106,6 @@ build-tw: "pkgconfig" "spdlog-devel" "zlib-devel" - "libQt5PlatformHeaders-devel" "cmake(re2)" "cmake(Qt6Core)" "cmake(Qt6DBus)" @@ -117,6 +116,7 @@ build-tw: "cmake(Qt6Svg)" "cmake(Qt6Widgets)" "cmake(Qt6Gui)" + "qt6-qml-private-devel" "pkgconfig(libcurl)" "pkgconfig(libevent)" "pkgconfig(gstreamer-webrtc-1.0)" diff --git a/CMakeLists.txt b/CMakeLists.txt
index dd356ae9..67eff75e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -357,6 +357,8 @@ set(SRC_FILES src/timeline/DelegateChooser.h src/timeline/EventStore.cpp src/timeline/EventStore.h + src/timeline/EventDelegateChooser.cpp + src/timeline/EventDelegateChooser.h src/timeline/InputBar.cpp src/timeline/InputBar.h src/timeline/Permissions.cpp @@ -693,7 +695,6 @@ set(QML_SOURCES resources/qml/ChatPage.qml resources/qml/CommunitiesList.qml resources/qml/RoomList.qml - resources/qml/TimelineView.qml resources/qml/Avatar.qml resources/qml/Completer.qml resources/qml/EncryptionIndicator.qml @@ -709,7 +710,12 @@ set(QML_SOURCES resources/qml/Reactions.qml resources/qml/ReplyPopup.qml resources/qml/StatusIndicator.qml - resources/qml/TimelineRow.qml + resources/qml/TimelineEvent.qml + resources/qml/TimelineSectionHeader.qml + resources/qml/TimelineDefaultMessageStyle.qml + resources/qml/TimelineBubbleMessageStyle.qml + resources/qml/TimelineMetadata.qml + resources/qml/TimelineView.qml resources/qml/TopBar.qml resources/qml/QuickSwitcher.qml resources/qml/ForwardCompleter.qml @@ -731,7 +737,6 @@ set(QML_SOURCES resources/qml/delegates/Encrypted.qml resources/qml/delegates/FileMessage.qml resources/qml/delegates/ImageMessage.qml - resources/qml/delegates/MessageDelegate.qml resources/qml/delegates/NoticeMessage.qml resources/qml/delegates/Pill.qml resources/qml/delegates/Placeholder.qml @@ -875,6 +880,7 @@ target_link_libraries(nheko PRIVATE Qt::Gui Qt::Multimedia Qt::Qml + Qt::QmlPrivate Qt::QuickControls2 qt6keychain nlohmann_json::nlohmann_json @@ -967,3 +973,4 @@ if(UNIX AND NOT APPLE) COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake) endif() endif() +# vim: tabstop=4 shiftwidth=4 expandtab diff --git a/man/nheko.1.adoc b/man/nheko.1.adoc
index 8327a061..cc4b8f74 100644 --- a/man/nheko.1.adoc +++ b/man/nheko.1.adoc
@@ -58,6 +58,10 @@ Creates a unique profile, which allows you to log into several accounts at the same time and start multiple instances of nheko. Use _default_ to start with the default profile. +*-C*, *--compact*:: +Allows shrinking the database, since LMDB databases don't automatically shrink +when data is deleted. Possibly allows some recovery on database corruption. + == FAQ === How do I add stickers and custom emojis? diff --git a/resources/langs/nheko_fr.ts b/resources/langs/nheko_fr.ts
index 94fe4ff3..bc6efc11 100644 --- a/resources/langs/nheko_fr.ts +++ b/resources/langs/nheko_fr.ts
@@ -210,12 +210,12 @@ <message> <location filename="../../src/voip/CallManager.cpp" line="+877"/> <source>X11</source> - <translation type="unfinished"></translation> + <translation>X11</translation> </message> <message> <location line="+3"/> <source>PipeWire</source> - <translation type="unfinished"></translation> + <translation>PipeWire</translation> </message> <message> <location line="+17"/> @@ -240,9 +240,11 @@ <location line="-631"/> <source>%n unread message(s) in room %1 </source> - <translation type="unfinished"> - <numerusform></numerusform> - <numerusform></numerusform> + <translation> + <numerusform>%n message non lu dans le salon %1 +</numerusform> + <numerusform>%n messages non lus dans le salon %1 +</numerusform> </translation> </message> <message> @@ -258,7 +260,7 @@ <message> <location line="+41"/> <source>Failed to open database, logging out!</source> - <translation>Impossible d&apos;ouvrir la base de données, déconnexion&#x202f;!</translation> + <translation>Impossible d&apos;ouvrir la base de données, déconnexion !</translation> </message> <message> <location line="+262"/> @@ -294,7 +296,7 @@ <message> <location line="-476"/> <source>Do you really want to invite %1 (%2)?</source> - <translation>Voulez-vous vraiment inviter %1 (%2)&#x202f;?</translation> + <translation>Voulez-vous vraiment inviter %1 (%2) ?</translation> </message> <message> <location line="+12"/> @@ -324,7 +326,7 @@ <message> <location line="+1"/> <source>Do you really want to unban %1 (%2)?</source> - <translation>Voulez-vous vraiment annuler le bannissement de %1 (%2)&#x202f;?</translation> + <translation>Voulez-vous vraiment annuler le bannissement de %1 (%2) ?</translation> </message> <message> <location line="+10"/> @@ -344,7 +346,7 @@ <message> <location line="-934"/> <source>Cache migration failed!</source> - <translation>Échec de la migration du cache&#x202f;!</translation> + <translation>Échec de la migration du cache !</translation> </message> <message> <location line="-87"/> @@ -392,7 +394,7 @@ Si vous pensez qu&apos;il s&apos;agit d&apos;une erreur, vous pouvez plutôt fer <location line="+34"/> <source>You failed to join %1. You can try to knock so that others can invite you in. Do you want to do so? You may optionally provide a reason for others to accept your knock:</source> - <translation>Vous n&apos;avez pas pu rejoindre %1. Vous pouvez essayer de frapper au salon afin que les autres membres vous invitent. Voulez-vous le faire&#x202f;? + <translation>Vous n&apos;avez pas pu rejoindre %1. Vous pouvez essayer de frapper au salon afin que les autres membres vous invitent. Voulez-vous le faire ? Vous pouvez éventuellement fournir une raison afin que les membres acceptent votre requête&#xa0;:</translation> </message> <message> @@ -403,7 +405,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+9"/> <source>Failed to remove invite: %1</source> - <translation>Impossible de supprimer l&apos;invitation&#x202f;: %1</translation> + <translation>Impossible de supprimer l&apos;invitation : %1</translation> </message> <message> <location line="+27"/> @@ -428,7 +430,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+15"/> <source>Failed to kick %1 from %2: %3</source> - <translation>Échec de l&apos;expulsion de %1 de %2&#x202f;&#x202f;: %3</translation> + <translation>Échec de l&apos;expulsion de %1 de %2  : %3</translation> </message> <message> <location line="+13"/> @@ -461,12 +463,12 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+2"/> <source>/join &lt;!roomid|#alias&gt; [reason]</source> - <translation type="unfinished"></translation> + <translation>/join &lt;!idsalon|#alias&gt; [raison]</translation> </message> <message> <location line="+2"/> <source>/knock &lt;!roomid|#alias&gt; [reason]</source> - <translation type="unfinished"></translation> + <translation>/knock &lt;!idsalon|#alias&gt; [raison]</translation> </message> <message> <location line="+2"/> @@ -1006,17 +1008,17 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+8"/> <source>Please verify the following digits. You should see the same numbers on both sides. If they differ, please press &apos;They do not match!&apos; to abort verification!</source> - <translation>Veuillez vérifier les chiffres suivants. Vous devriez voir les mêmes chiffres des deux côtés. Si ceux-ci diffèrent, veuillez choisir «&#x202f;Ils sont différents&#x202f;!&#x202f;» pour annuler la vérification&#x202f;!</translation> + <translation>Veuillez vérifier les chiffres suivants. Vous devriez voir les mêmes chiffres des deux côtés. Si ceux-ci diffèrent, veuillez choisir « Ils sont différents ! » pour annuler la vérification !</translation> </message> <message> <location line="+33"/> <source>They do not match!</source> - <translation>Ils sont différents&#x202f;!</translation> + <translation>Ils sont différents !</translation> </message> <message> <location line="+13"/> <source>They match!</source> - <translation>Ils sont identiques&#x202f;!</translation> + <translation>Ils sont identiques !</translation> </message> </context> <context> @@ -1029,7 +1031,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+8"/> <source>Please verify the following emoji. You should see the same emoji on both sides. If they differ, please press &apos;They do not match!&apos; to abort verification!</source> - <translation>Veuillez vérifier les émoji suivants. Vous devriez voir les mêmes émoji des deux côtés. S&apos;ils diffèrent, veuillez choisir « Ils sont différents&#x202f;!&#x202f;» pour annuler la vérification&#x202f;!</translation> + <translation>Veuillez vérifier les émoji suivants. Vous devriez voir les mêmes émoji des deux côtés. S&apos;ils diffèrent, veuillez choisir « Ils sont différents ! » pour annuler la vérification !</translation> </message> <message> <location line="+379"/> @@ -1039,12 +1041,12 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+10"/> <source>They do not match!</source> - <translation>Ils sont différents&#x202f;!</translation> + <translation>Ils sont différents !</translation> </message> <message> <location line="+13"/> <source>They match!</source> - <translation>Ils sont identiques&#x202f;!</translation> + <translation>Ils sont identiques !</translation> </message> </context> <context> @@ -1087,7 +1089,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+9"/> <source>Request key</source> - <translation>Demander la clef</translation> + <translation>Demander la clé</translation> </message> </context> <context> @@ -1108,7 +1110,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location filename="../qml/EncryptionIndicator.qml" line="+36"/> <source>This message is not encrypted!</source> - <translation>Ce message n&apos;est pas chiffré&#x202f;!</translation> + <translation>Ce message n&apos;est pas chiffré !</translation> </message> <message> <location line="+3"/> @@ -1213,7 +1215,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+4"/> <source>Key mismatch detected!</source> - <translation>Clés non correspondantes détectées&#x202f;!</translation> + <translation>Clés non correspondantes détectées !</translation> </message> <message> <location line="+2"/> @@ -1228,7 +1230,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <message> <location line="+2"/> <source>Verification messages received out of order!</source> - <translation>Messages de vérification reçus dans le désordre&#x202f;!</translation> + <translation>Messages de vérification reçus dans le désordre !</translation> </message> <message> <location line="+2"/> @@ -1553,7 +1555,7 @@ Vous pouvez éventuellement fournir une raison afin que les membres acceptent vo <location line="+64"/> <location line="+127"/> <source>You have entered an invalid Matrix ID e.g @joe:matrix.org</source> - <translation>Vous avez entré un identifiant Matrix invalide exemple correct&#x202f;: @moi:monserveur.example.com)</translation> + <translation>Vous avez entré un identifiant Matrix invalide exemple correct : @moi:monserveur.example.com)</translation> </message> <message> <location line="-157"/> @@ -1700,12 +1702,12 @@ Example: https://server.my:8787</source> <message> <location line="+1"/> <source>A call is in progress. Log out?</source> - <translation>Un appel est en cours. Se déconnecter&#x202f;?</translation> + <translation>Un appel est en cours. Se déconnecter ?</translation> </message> <message> <location line="+0"/> <source>Are you sure you want to log out?</source> - <translation>Êtes-vous certain de vouloir vous déconnecter&#x202f;?</translation> + <translation>Êtes-vous certain de vouloir vous déconnecter ?</translation> </message> </context> <context> @@ -2127,7 +2129,7 @@ Example: https://server.my:8787</source> <message> <location filename="../qml/voip/PlaceCall.qml" line="+39"/> <source>Place a call to %1?</source> - <translation>Appeler %1&#x202f;?</translation> + <translation>Appeler %1 ?</translation> </message> <message> <location line="+16"/> @@ -3369,7 +3371,7 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <message> <location filename="../qml/voip/ScreenShare.qml" line="+30"/> <source>Share desktop with %1?</source> - <translation>Partager le bureau avec %1&#x202f;&#x202f;?</translation> + <translation>Partager le bureau avec %1  ?</translation> </message> <message> <location line="+11"/> @@ -3379,7 +3381,7 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <message> <location line="+20"/> <source>Window:</source> - <translation>Fenêtre&#x202f;:</translation> + <translation>Fenêtre :</translation> </message> <message> <location line="+15"/> @@ -3389,7 +3391,7 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <message> <location line="+16"/> <source>Frame rate:</source> - <translation>Fréquence d&apos;images&#x202f;:</translation> + <translation>Fréquence d&apos;images :</translation> </message> <message> <location line="+19"/> @@ -3438,7 +3440,7 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <message> <location line="+1"/> <source>Nheko could not connect to the secure storage to save encryption secrets to. This can have multiple reasons. Check if your D-Bus service is running and you have configured a service like KWallet, Gnome Keyring, KeePassXC or the equivalent for your platform. If you are having trouble, feel free to open an issue here: https://github.com/Nheko-Reborn/nheko/issues</source> - <translation>Nheko n&apos;a pas pu se connecter au stockage sécurisé afin d&apos;y sauvegarder les clés de chiffrement. Cela peut avoir différentes causes. Vérifiez si votre service D-Bus est lancé, et si vous avez configuré un service tel que KWallet&#x202f;; Gnome Keyring&#x202f;; KeePassXC ou l&apos;équivalent pour votre système. Si vous n&apos;arrivez pas à résoudre le problème, n&apos;hésitez pas à nous en faire part ici&#x202f;: https&#x202f;://github.com/Nheko-Reborn/nheko/issues</translation> + <translation>Nheko n&apos;a pas pu se connecter au stockage sécurisé afin d&apos;y sauvegarder les clés de chiffrement. Cela peut avoir différentes causes. Vérifiez si votre service D-Bus est lancé, et si vous avez configuré un service tel que KWallet ; Gnome Keyring ; KeePassXC ou l&apos;équivalent pour votre système. Si vous n&apos;arrivez pas à résoudre le problème, n&apos;hésitez pas à nous en faire part ici : https ://github.com/Nheko-Reborn/nheko/issues</translation> </message> </context> <context> @@ -3446,7 +3448,7 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <message> <location filename="../qml/SelfVerificationCheck.qml" line="+50"/> <source>This is your recovery key. You will need it to restore access to your encrypted messages and verification keys. Keep this safe. Don&apos;t share it with anyone and don&apos;t lose it! Do not pass go! Do not collect $200!</source> - <translation>Ceci est votre clé de récupération. Vous en aurez besoin afin de restaurer l&apos;accès à vos messages chiffrés et à vos clés de vérification. Gardez cette clé en sûreté. Ne la partagez pas avec qui que ce soit et ne la perdez pas&#x202f;! Ne passez pas par la case départ et ne recevez pas 20 000 francs&#x202f;!</translation> + <translation>Ceci est votre clé de récupération. Vous en aurez besoin afin de restaurer l&apos;accès à vos messages chiffrés et à vos clés de vérification. Gardez cette clé en sûreté. Ne la partagez pas avec qui que ce soit et ne la perdez pas ! Ne passez pas par la case départ et ne recevez pas 20 000 francs !</translation> </message> <message> <location line="+21"/> @@ -3467,8 +3469,8 @@ Veuillez noter qu&apos;il ne pourra plus être désactivé par la suite.</transl <location line="+9"/> <source>Hello and welcome to Matrix! It seems like you are new. Before you can securely encrypt your messages, we need to setup a few small things. You can either press accept immediately or adjust a few basic options. We also try to explain a few of the basics. You can skip those parts, but they might prove to be helpful!</source> - <translation>Bonjour et bienvenue sur le réseau Matrix&#x202f;! -Il semblerait que ce soit votre première fois ici. Avant de pouvoir chiffrer vos messages de manière sécurisée, nous devons configurer quelques détails. Vous pouvez soit accepter immédiatement, soit ajuster quelques options basiques. Nous essayons également d&apos;expliquer le fonctionnement de certains mécanismes. Vous pouvez sauter ces étapes, mais celles-ci pourraient se montrer utiles par la suite&#x202f;!</translation> + <translation>Bonjour et bienvenue sur le réseau Matrix ! +Il semblerait que ce soit votre première fois ici. Avant de pouvoir chiffrer vos messages de manière sécurisée, nous devons configurer quelques détails. Vous pouvez soit accepter immédiatement, soit ajuster quelques options basiques. Nous essayons également d&apos;expliquer le fonctionnement de certains mécanismes. Vous pouvez sauter ces étapes, mais celles-ci pourraient se montrer utiles par la suite !</translation> </message> <message> <location line="+108"/> @@ -3498,17 +3500,17 @@ Si vous choisissez de vérifier, vous aurez besoin de l&apos;autre appareil. Si <message> <location filename="../../src/encryption/SelfVerificationStatus.cpp" line="+50"/> <source>Failed to create keys for cross-signing!</source> - <translation>Échec de la création des clés pour l&apos;auto-vérification (cross-signing)&#x202f;!</translation> + <translation>Échec de la création des clés pour l&apos;auto-vérification (cross-signing) !</translation> </message> <message> <location line="+16"/> <source>Failed to create keys for online key backup!</source> - <translation>Échec de la création de clés pour la sauvegarde en ligne&#x202f;!</translation> + <translation>Échec de la création de clés pour la sauvegarde en ligne !</translation> </message> <message> <location line="+29"/> <source>Failed to create keys for secure server side secret storage!</source> - <translation>Échec de la création des clés pour le stockage sécurisé côté serveur&#x202f;!</translation> + <translation>Échec de la création des clés pour le stockage sécurisé côté serveur !</translation> </message> <message> <location line="+44"/> @@ -3518,7 +3520,7 @@ Si vous choisissez de vérifier, vous aurez besoin de l&apos;autre appareil. Si <message> <location line="+6"/> <source>Encryption setup failed: %1</source> - <translation>Échec de la configuration du chiffrement&#x202f;: %1</translation> + <translation>Échec de la configuration du chiffrement : %1</translation> </message> <message> <location line="+156"/> @@ -3636,7 +3638,7 @@ Si vous choisissez de vérifier, vous aurez besoin de l&apos;autre appareil. Si <message> <location line="+10"/> <source>Verification successful! Both sides verified their devices!</source> - <translation>Vérification réussie&#x202f;! Les deux côtés ont vérifié leur appareil&#x202f;!</translation> + <translation>Vérification réussie ! Les deux côtés ont vérifié leur appareil !</translation> </message> <message> <location line="+14"/> @@ -3656,7 +3658,7 @@ Si vous choisissez de vérifier, vous aurez besoin de l&apos;autre appareil. Si <location line="+115"/> <location line="+5"/> <source>Failed to encrypt event, sending aborted!</source> - <translation>Échec du chiffrement de l&apos;évènement, envoi abandonné&#x202f;!</translation> + <translation>Échec du chiffrement de l&apos;évènement, envoi abandonné !</translation> </message> <message> <location line="+196"/> @@ -4036,7 +4038,7 @@ Raison : %4</translation> <location line="+36"/> <source>%1 left after having already left!</source> <comment>This is a leave event after the user already left and shouldn&apos;t happen apart from state resets</comment> - <translation>%1 a quitté le salon après l&apos;avoir déjà quitté&#x202f;!</translation> + <translation>%1 a quitté le salon après l&apos;avoir déjà quitté !</translation> </message> <message> <location line="+7"/> diff --git a/resources/qml/CommunitiesList.qml b/resources/qml/CommunitiesList.qml
index 81f0640e..53578cd0 100644 --- a/resources/qml/CommunitiesList.qml +++ b/resources/qml/CommunitiesList.qml
@@ -5,11 +5,11 @@ import "./components" import "./dialogs" import Qt.labs.platform 1.1 as Platform -import QtQml 2.12 -import QtQuick 2.12 -import QtQuick.Controls 2.5 -import QtQuick.Layouts 1.3 -import im.nheko 1.0 +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import im.nheko Page { id: communitySidebar @@ -38,6 +38,7 @@ Page { anchors.right: parent.right height: parent.height model: Communities.filtered() + boundsBehavior: Flickable.StopAtBounds ScrollBar.vertical: ScrollBar { id: scrollbar @@ -143,11 +144,11 @@ Page { enabled: false height: avatarSize roomid: model.id - textColor: model.avatarUrl.startsWith(":/") ? communityItem.unimportantText : communityItem.importantText + textColor: model.avatarUrl?.startsWith(":/") == true ? communityItem.unimportantText : communityItem.importantText url: { - if (model.avatarUrl.startsWith("mxc://")) + if (model.avatarUrl?.startsWith("mxc://") == true) return model.avatarUrl.replace("mxc://", "image://MxcImage/"); - else if (model.avatarUrl.length > 0) + else if ((model.avatarUrl?.length ?? 0) > 0) return model.avatarUrl; else return ""; diff --git a/resources/qml/Completer.qml b/resources/qml/Completer.qml
index 590d5bb8..9ebe0a40 100644 --- a/resources/qml/Completer.qml +++ b/resources/qml/Completer.qml
@@ -145,7 +145,6 @@ Control { roleValue: "user" RowLayout { - anchors.centerIn: centerRowContent ? parent : undefined spacing: rowSpacing @@ -171,7 +170,6 @@ Control { roleValue: "emoji" RowLayout { - anchors.centerIn: parent spacing: rowSpacing @@ -207,7 +205,6 @@ Control { roleValue: "command" RowLayout { - anchors.centerIn: parent spacing: rowSpacing @@ -226,7 +223,6 @@ Control { roleValue: "room" RowLayout { - anchors.centerIn: centerRowContent ? parent : undefined spacing: rowSpacing @@ -251,7 +247,6 @@ Control { roleValue: "roomAliases" RowLayout { - anchors.centerIn: parent spacing: rowSpacing diff --git a/resources/qml/EncryptionIndicator.qml b/resources/qml/EncryptionIndicator.qml
index fb9dc7b5..347220d7 100644 --- a/resources/qml/EncryptionIndicator.qml +++ b/resources/qml/EncryptionIndicator.qml
@@ -59,8 +59,6 @@ Image { return sourceUrl + (stateImg.hovered ? unencryptedHoverColor : unencryptedColor); } } - sourceSize.height: height - sourceSize.width: width width: 16 HoverHandler { diff --git a/resources/qml/ForwardCompleter.qml b/resources/qml/ForwardCompleter.qml
index 0174e0f6..8ed6017c 100644 --- a/resources/qml/ForwardCompleter.qml +++ b/resources/qml/ForwardCompleter.qml
@@ -10,7 +10,7 @@ import im.nheko 1.0 Popup { id: forwardMessagePopup - property var mid + property string mid: "" function setMessageEventId(mid_in) { mid = mid_in; @@ -54,25 +54,9 @@ Popup { Reply { id: replyPreview - property var modelData: room ? room.getDump(mid, "") : {} - - blurhash: modelData.blurhash ?? "" - body: modelData.body ?? "" - encryptionError: modelData.encryptionError ?? "" - eventId: modelData.eventId ?? "" - filename: modelData.filename ?? "" - filesize: modelData.filesize ?? "" - formattedBody: modelData.formattedBody ?? "" - isOnlyEmoji: modelData.isOnlyEmoji ?? false - originalWidth: modelData.originalWidth ?? 0 - proportionalHeight: modelData.proportionalHeight ?? 1 - type: modelData.type ?? MtxEvent.UnknownMessage - typeString: modelData.typeString ?? "" - url: modelData.url ?? "" - userColor: TimelineManager.userColor(modelData.userId, palette.window) - userId: modelData.userId ?? "" - userName: modelData.userName ?? "" - width: parent.width + eventId: mid + userColor: TimelineManager.userColor(replyPreview.userId, palette.window) + maxWidth: parent.width } MatrixTextField { id: roomTextInput @@ -80,7 +64,7 @@ Popup { color: palette.text width: forwardMessagePopup.width - forwardMessagePopup.leftPadding * 2 - Keys.onPressed: { + Keys.onPressed: (event) => { if (event.key == Qt.Key_Up || event.key == Qt.Key_Backtab) { event.accepted = true; completerPopup.up(); @@ -113,7 +97,7 @@ Popup { } Connections { function onCompletionSelected(id) { - room.forwardMessage(messageContextMenu.eventId, id); + room.forwardMessage(forwardMessagePopup.mid, id); forwardMessagePopup.close(); } function onCountChanged() { diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml
index b3a8a05a..bdc0cb6b 100644 --- a/resources/qml/MatrixText.qml +++ b/resources/qml/MatrixText.qml
@@ -6,29 +6,28 @@ import QtQuick import QtQuick.Controls import im.nheko -TextEdit { +TextArea { id: r property alias cursorShape: cs.cursorShape - //leftInset: 0 - //bottomInset: 0 - //rightInset: 0 - //topInset: 0 - //leftPadding: 0 - //bottomPadding: 0 - //rightPadding: 0 - //topPadding: 0 - //background: null - ToolTip.text: hoveredLink ToolTip.visible: hoveredLink || false + background: null + bottomInset: 0 + bottomPadding: 0 // this always has to be enabled, otherwise you can't click links anymore! //enabled: selectByMouse color: palette.text focus: false + leftInset: 0 + leftPadding: 0 readOnly: true + rightInset: 0 + rightPadding: 0 textFormat: TextEdit.RichText + topInset: 0 + topPadding: 0 wrapMode: Text.Wrap // Setting a tooltip delay makes the hover text empty .-. @@ -38,9 +37,9 @@ TextEdit { } onLinkActivated: Nheko.openLink(link) - //// propagate events up - //onPressAndHold: (event) => event.accepted = false - //onPressed: (event) => event.accepted = (event.button == Qt.LeftButton) + // propagate events up + onPressAndHold: event => event.accepted = false + onPressed: event => event.accepted = (event.button == Qt.LeftButton) NhekoCursorShape { id: cs diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml
index a0ff0ff1..df39f3d1 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml
@@ -20,12 +20,13 @@ Item { property int availableWidth: width property int padding: Nheko.paddingMedium property string searchString: "" + property Room roommodel: room // HACK: https://bugreports.qt.io/browse/QTBUG-83972, qtwayland cannot auto hide menu Connections { function onHideMenu() { - messageContextMenu.close(); - replyContextMenu.close(); + messageContextMenuC.close(); + replyContextMenuC.close(); } target: MainWindow @@ -51,182 +52,42 @@ Item { //onModelChanged: if (room) room.sendReset() //reuseItems: true boundsBehavior: Flickable.StopAtBounds - displayMarginBeginning: height / 2 - displayMarginEnd: height / 2 + displayMarginBeginning: height / 4 + displayMarginEnd: height / 4 model: (filteredTimeline.filterByThread || filteredTimeline.filterByContent) ? filteredTimeline : room //pixelAligned: true spacing: 2 verticalLayoutDirection: ListView.BottomToTop - delegate: Item { - id: wrapper - - required property string blurhash - required property string body - required property string callType - required property var day - required property string duration - required property int encryptionError - required property string eventId - required property string filename - required property string filesize - required property string formattedBody - required property int index - required property bool isEditable - required property bool isEdited - required property bool isEncrypted - required property bool isOnlyEmoji - required property bool isSender - required property bool isStateEvent - required property int notificationlevel - required property int originalWidth - property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day) - property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent) - property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId) - required property double proportionalHeight - required property var reactions - required property int relatedEventCacheBuster - required property string replyTo - required property string roomName - required property string roomTopic - property bool scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) - required property int status - required property string threadId - required property string thumbnailUrl - required property var timestamp - required property int trustlevel - required property int type - required property string typeString - required property string url - required property string userId - required property string userName - required property int userPowerlevel - - ListView.delayRemove: true - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - height: (section.item?.height ?? 0) + timelinerow.height - width: chat.delegateMaxWidth - - Loader { - id: section - - property var day: wrapper.day - property bool isSender: wrapper.isSender - property bool isStateEvent: wrapper.isStateEvent - property int parentWidth: parent.width - property var previousMessageDay: wrapper.previousMessageDay - property bool previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent - property string previousMessageUserId: wrapper.previousMessageUserId - property date timestamp: wrapper.timestamp - property string userId: wrapper.userId - property string userName: wrapper.userName - property int userPowerlevel: wrapper.userPowerlevel - - active: previousMessageUserId !== userId || previousMessageDay !== day || previousMessageIsStateEvent !== isStateEvent - //asynchronous: true - sourceComponent: sectionHeader - visible: status == Loader.Ready - z: 4 - } - TimelineRow { - id: timelinerow - - blurhash: wrapper.blurhash - body: wrapper.body - callType: wrapper.callType - duration: wrapper.duration - encryptionError: wrapper.encryptionError - eventId: chat.model, wrapper.eventId - filename: wrapper.filename - filesize: wrapper.filesize - formattedBody: wrapper.formattedBody - index: wrapper.index - isEditable: wrapper.isEditable - isEdited: wrapper.isEdited - isEncrypted: wrapper.isEncrypted - isOnlyEmoji: wrapper.isOnlyEmoji - isSender: wrapper.isSender - isStateEvent: wrapper.isStateEvent - notificationlevel: wrapper.notificationlevel - originalWidth: wrapper.originalWidth - proportionalHeight: wrapper.proportionalHeight - reactions: wrapper.reactions - relatedEventCacheBuster: wrapper.relatedEventCacheBuster - replyTo: wrapper.replyTo - roomName: wrapper.roomName - roomTopic: wrapper.roomTopic - status: wrapper.status - threadId: wrapper.threadId - thumbnailUrl: wrapper.thumbnailUrl - timestamp: wrapper.timestamp - trustlevel: wrapper.trustlevel - type: chat.model, wrapper.type - typeString: wrapper.typeString - url: wrapper.url - userId: wrapper.userId - userName: wrapper.userName - width: wrapper.width - y: section.visible && section.active ? section.y + section.height : 0 - - background: Rectangle { - id: scrollHighlight - - color: palette.highlight - enabled: false - opacity: 0 - visible: true - z: 1 - - states: State { - name: "revealed" - when: wrapper.scrolledToThis - } - transitions: Transition { - from: "" - to: "revealed" - - SequentialAnimation { - PropertyAnimation { - duration: 500 - easing.type: Easing.InOutQuad - from: 0 - properties: "opacity" - target: scrollHighlight - to: 1 - } - PropertyAnimation { - duration: 500 - easing.type: Easing.InOutQuad - from: 1 - properties: "opacity" - target: scrollHighlight - to: 0 - } - ScriptAction { - script: room.eventShown() - } - } - } - } + property int lastScrollPos: 0 - onHoveredChanged: { - if (!Settings.mobileMode && hovered) { - if (!messageActions.hovered) { - messageActions.attached = timelinerow; - messageActions.model = timelinerow; - } - } - } + // Fixup the scroll position when the height changes. Without this, the view is kept around the center of the currently visible content, while we usually want to stick to the bottom. + onMovementEnded: lastScrollPos = (contentY+height) + onModelChanged: lastScrollPos = (contentY+height) + onHeightChanged: contentY = (lastScrollPos-height) + + Component { + id: defaultMessageStyle + + TimelineDefaultMessageStyle { + messageActions: messageActionsC + messageContextMenu: messageContextMenuC + replyContextMenu: replyContextMenuC + scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) } - Connections { - function onMovementEnded() { - if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) - chat.model.currentIndex = index; - } + } + Component { + id: bubbleMessageStyle - target: chat + TimelineBubbleMessageStyle { + messageActions: messageActionsC + messageContextMenu: messageContextMenuC + replyContextMenu: replyContextMenuC + scrolledToThis: eventId === room.scrollTarget && (y + height > chat.y + chat.contentY && y < chat.y + chat.height + chat.contentY) } } + + delegate: Settings.bubbles ? bubbleMessageStyle : defaultMessageStyle footer: Item { anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Nheko.paddingLarge @@ -260,19 +121,19 @@ Item { source: room } Control { - id: messageActions + id: messageActionsC property Item attached: null // use comma to update on scroll - property var attachedPos: chat.contentY, attached ? chat.mapFromItem(attached, attached ? attached.width - width : 0, -height) : null property alias model: row.model hoverEnabled: true padding: Nheko.paddingSmall visible: Settings.buttonsInTimeline && !!attached && (attached.hovered || hovered) - x: attached ? attachedPos.x : 0 - y: attached ? attachedPos.y + Nheko.paddingSmall : 0 z: 10 + parent: chat.contentItem + anchors.bottom: attached?.top + anchors.right: attached?.right background: Rectangle { border.color: palette.buttonText @@ -285,7 +146,7 @@ Item { property var model - spacing: messageActions.padding + spacing: messageActionsC.padding Repeater { model: Settings.recentReactions @@ -422,7 +283,7 @@ Item { image: ":/icons/icons/ui/options.svg" width: 16 - onClicked: messageContextMenu.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) + onClicked: messageContextMenuC.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) } } } @@ -504,146 +365,9 @@ Item { room.setCurrentIndex(room.currentIndex); } } - Component { - id: sectionHeader - - Column { - bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3 - spacing: 8 - topPadding: userName_.visible ? 4 : 0 - visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent) - width: parentWidth - - Label { - id: dateBubble - - anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined - color: palette.text - height: Math.round(fontMetrics.height * 1.4) - horizontalAlignment: Text.AlignHCenter - text: room ? room.formatDateSeparator(timestamp) : "" - verticalAlignment: Text.AlignVCenter - visible: room && previousMessageDay !== day - width: contentWidth * 1.2 - - background: Rectangle { - color: palette.window - radius: parent.height / 2 - } - } - Row { - id: userInfo - - property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width - - height: userName_.height - spacing: 8 - visible: !isStateEvent && (!isSender || !Settings.bubbles) - - Avatar { - id: messageUserAvatar - - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: userid - ToolTip.visible: messageUserAvatar.hovered - displayName: userName - height: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) - url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") - userid: userId - width: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) - - onClicked: room.openUserProfile(userId) - } - Connections { - function onRoomAvatarUrlChanged() { - messageUserAvatar.url = room.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); - } - function onScrollToIndex(index) { - chat.positionViewAtIndex(index, ListView.Center); - } - - target: room - } - - AbstractButton { - id: userNameButton - - PowerlevelIndicator { - id: powerlevelIndicator - anchors.left: parent.left - //anchors.horizontalCenter: parent.horizontalCenter - - powerlevel: userPowerlevel - permissions: room ? room.permissions : null - visible: isAdmin || isModerator - } - - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: userId - ToolTip.visible: hovered - leftPadding: powerlevelIndicator.visible ? 16 : 0 - leftInset: 0 - rightInset: 0 - rightPadding: 0 - - contentItem: Label { - id: userName_ - - color: TimelineManager.userColor(userId, palette.base) - text: TimelineManager.escapeEmoji(userNameTextMetrics.elidedText) - textFormat: Text.RichText - } - - onClicked: room.openUserProfile(userId) - - TextMetrics { - id: userNameTextMetrics - - elide: Text.ElideRight - elideWidth: userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3) - text: userName - } - NhekoCursorShape { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - } - } - Label { - id: statusMsg - - property string userStatus: Presence.userStatus(userId) - - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("%1's status message").arg(userName) - ToolTip.visible: statusMsgHoverHandler.hovered - anchors.baseline: userNameButton.baseline - color: palette.buttonText - elide: Text.ElideRight - font.italic: true - font.pointSize: Math.floor(fontMetrics.font.pointSize * 0.8) - text: userStatus.replace(/\n/g, " ") - textFormat: Text.PlainText - width: Math.min(implicitWidth, userInfo.remainingWidth - userName_.width - parent.spacing) - - HoverHandler { - id: statusMsgHoverHandler - - } - Connections { - function onPresenceChanged(id) { - if (id == userId) - statusMsg.userStatus = Presence.userStatus(userId); - } - - target: Presence - } - } - } - } - } } Platform.Menu { - id: messageContextMenu + id: messageContextMenuC property string eventId property int eventType @@ -698,22 +422,22 @@ Item { onTriggered: function () { topBar.searchString = ""; - room.showEvent(messageContextMenu.eventId); + room.showEvent(messageContextMenuC.eventId); } } Platform.MenuItem { enabled: visible text: qsTr("&Copy") - visible: messageContextMenu.text + visible: messageContextMenuC.text - onTriggered: Clipboard.text = messageContextMenu.text + onTriggered: Clipboard.text = messageContextMenuC.text } Platform.MenuItem { enabled: visible text: qsTr("Copy &link location") - visible: messageContextMenu.link + visible: messageContextMenuC.link - onTriggered: Clipboard.text = messageContextMenu.link + onTriggered: Clipboard.text = messageContextMenuC.link } Platform.MenuItem { id: reactionOption @@ -722,7 +446,7 @@ Item { visible: room ? room.permissions.canSend(MtxEvent.Reaction) : false onTriggered: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(null, room.roomId, function (plaintext, markdown) { - room.input.reaction(messageContextMenu.eventId, plaintext); + room.input.reaction(messageContextMenuC.eventId, plaintext); TimelineManager.focusMessageInput(); }) } @@ -730,41 +454,41 @@ Item { text: qsTr("Repl&y") visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false - onTriggered: room.reply = (messageContextMenu.eventId) + onTriggered: room.reply = (messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Edit") - visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) + visible: messageContextMenuC.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) - onTriggered: room.edit = (messageContextMenu.eventId) + onTriggered: room.edit = (messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Thread") visible: (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) - onTriggered: room.thread = (messageContextMenu.threadId || messageContextMenu.eventId) + onTriggered: room.thread = (messageContextMenuC.threadId || messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible - text: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? qsTr("Un&pin") : qsTr("&Pin") + text: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? qsTr("Un&pin") : qsTr("&Pin") visible: (room ? room.permissions.canChange(MtxEvent.PinnedEvents) : false) - onTriggered: visible && room.pinnedMessages.includes(messageContextMenu.eventId) ? room.unpin(messageContextMenu.eventId) : room.pin(messageContextMenu.eventId) + onTriggered: visible && room.pinnedMessages.includes(messageContextMenuC.eventId) ? room.unpin(messageContextMenuC.eventId) : room.pin(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("&Read receipts") - onTriggered: room.showReadReceipts(messageContextMenu.eventId) + onTriggered: room.showReadReceipts(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("&Forward") - visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker || messageContextMenu.eventType == MtxEvent.TextMessage || messageContextMenu.eventType == MtxEvent.LocationMessage || messageContextMenu.eventType == MtxEvent.EmoteMessage || messageContextMenu.eventType == MtxEvent.NoticeMessage + visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker || messageContextMenuC.eventType == MtxEvent.TextMessage || messageContextMenuC.eventType == MtxEvent.LocationMessage || messageContextMenuC.eventType == MtxEvent.EmoteMessage || messageContextMenuC.eventType == MtxEvent.NoticeMessage onTriggered: { var forwardMess = forwardCompleterComponent.createObject(timelineRoot); - forwardMess.setMessageEventId(messageContextMenu.eventId); + forwardMess.setMessageEventId(messageContextMenuC.eventId); forwardMess.open(); timelineRoot.destroyOnClose(forwardMess); } @@ -775,23 +499,23 @@ Item { Platform.MenuItem { text: qsTr("View raw message") - onTriggered: room.viewRawMessage(messageContextMenu.eventId) + onTriggered: room.viewRawMessage(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("View decrypted raw message") // TODO(Nico): Fix this still being iterated over, when using keyboard to select options - visible: messageContextMenu.isEncrypted + visible: messageContextMenuC.isEncrypted - onTriggered: room.viewDecryptedRawMessage(messageContextMenu.eventId) + onTriggered: room.viewDecryptedRawMessage(messageContextMenuC.eventId) } Platform.MenuItem { text: qsTr("Remo&ve message") - visible: (room ? room.permissions.canRedact() : false) || messageContextMenu.isSender + visible: (room ? room.permissions.canRedact() : false) || messageContextMenuC.isSender onTriggered: function () { var dialog = removeReason.createObject(timelineRoot); - dialog.eventId = messageContextMenu.eventId; + dialog.eventId = messageContextMenuC.eventId; dialog.show(); dialog.forceActiveFocus(); timelineRoot.destroyOnClose(dialog); @@ -800,23 +524,23 @@ Item { Platform.MenuItem { enabled: visible text: qsTr("&Save as") - visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker + visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker - onTriggered: room.saveMedia(messageContextMenu.eventId) + onTriggered: room.saveMedia(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("&Open in external program") - visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker + visible: messageContextMenuC.eventType == MtxEvent.ImageMessage || messageContextMenuC.eventType == MtxEvent.VideoMessage || messageContextMenuC.eventType == MtxEvent.AudioMessage || messageContextMenuC.eventType == MtxEvent.FileMessage || messageContextMenuC.eventType == MtxEvent.Sticker - onTriggered: room.openMedia(messageContextMenu.eventId) + onTriggered: room.openMedia(messageContextMenuC.eventId) } Platform.MenuItem { enabled: visible text: qsTr("Copy link to eve&nt") - visible: messageContextMenu.eventId + visible: messageContextMenuC.eventId - onTriggered: room.copyLinkToEvent(messageContextMenu.eventId) + onTriggered: room.copyLinkToEvent(messageContextMenuC.eventId) } } Component { @@ -826,7 +550,7 @@ Item { } } Platform.Menu { - id: replyContextMenu + id: replyContextMenuC property string eventId property string link @@ -842,23 +566,23 @@ Item { Platform.MenuItem { enabled: visible text: qsTr("&Copy") - visible: replyContextMenu.text + visible: replyContextMenuC.text - onTriggered: Clipboard.text = replyContextMenu.text + onTriggered: Clipboard.text = replyContextMenuC.text } Platform.MenuItem { enabled: visible text: qsTr("Copy &link location") - visible: replyContextMenu.link + visible: replyContextMenuC.link - onTriggered: Clipboard.text = replyContextMenu.link + onTriggered: Clipboard.text = replyContextMenuC.link } Platform.MenuItem { enabled: visible text: qsTr("&Go to quoted message") visible: true - onTriggered: room.showEvent(replyContextMenu.eventId) + onTriggered: room.showEvent(replyContextMenuC.eventId) } } RoundButton { diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml
index eff62fc1..5b994145 100644 --- a/resources/qml/Reactions.qml +++ b/resources/qml/Reactions.qml
@@ -74,10 +74,10 @@ Flow { anchors.verticalCenter: divider.verticalCenter fillMode: Image.PreserveAspectFit height: textMetrics.height + mipmap: true source: modelData.key.startsWith("mxc://") ? (modelData.key.replace("mxc://", "image://MxcImage/") + "?scale") : "" visible: modelData.key.startsWith("mxc://") width: textMetrics.height - mipmap: true } Rectangle { id: divider diff --git a/resources/qml/ReplyPopup.qml b/resources/qml/ReplyPopup.qml
index ce24297c..17ce7ee4 100644 --- a/resources/qml/ReplyPopup.qml +++ b/resources/qml/ReplyPopup.qml
@@ -29,24 +29,10 @@ Rectangle { anchors.rightMargin: replyPopup.width < 450 ? 2 * (22 + 16) : 3 * (22 + 16) anchors.top: parent.top anchors.topMargin: Nheko.paddingSmall - blurhash: modelData.blurhash ?? "" - body: modelData.body ?? "" - encryptionError: modelData.encryptionError ?? 0 - eventId: modelData.eventId ?? "" - filename: modelData.filename ?? "" - filesize: modelData.filesize ?? "" - formattedBody: modelData.formattedBody ?? "" - isOnlyEmoji: modelData.isOnlyEmoji ?? false - originalWidth: modelData.originalWidth ?? 0 - proportionalHeight: modelData.proportionalHeight ?? 1 - type: modelData.type ?? MtxEvent.UnknownMessage - typeString: modelData.typeString ?? "" - url: modelData.url ?? "" + eventId: room?.reply ?? "" userColor: TimelineManager.userColor(modelData.userId, palette.window) - userId: modelData.userId ?? "" - userName: modelData.userName ?? "" visible: room && room.reply - width: parent.width + maxWidth: parent.width - anchors.leftMargin - anchors.rightMargin } ImageButton { id: closeReplyButton diff --git a/resources/qml/RoomList.qml b/resources/qml/RoomList.qml
index 20e5b95b..eab6520e 100644 --- a/resources/qml/RoomList.qml +++ b/resources/qml/RoomList.qml
@@ -6,11 +6,11 @@ import "./components" import "./dialogs" import "./ui" import Qt.labs.platform 1.1 as Platform -import QtQml 2.12 -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.3 -import im.nheko 1.0 +import QtQml +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import im.nheko Page { //leftPadding: Nheko.paddingSmall @@ -405,6 +405,7 @@ Page { anchors.right: parent.right height: parent.height model: Rooms + boundsBehavior: Flickable.StopAtBounds //reuseItems: true ScrollBar.vertical: ScrollBar { @@ -728,9 +729,9 @@ Page { } Platform.MenuItem { text: qsTr("Mark as read") + onTriggered: Rooms.getRoomById(roomContextMenu.roomid).markRoomAsRead() } - Platform.MenuItem { text: qsTr("Room settings") diff --git a/resources/qml/Root.qml b/resources/qml/Root.qml
index 1e8a6a27..09a8f442 100644 --- a/resources/qml/Root.qml +++ b/resources/qml/Root.qml
@@ -355,7 +355,6 @@ Pane { onAccepted: UIA.continue3pidReceived() } - Connections { function onConfirm3pidToken() { uiaConfirmationLinkDialog.open(); @@ -363,6 +362,18 @@ Pane { function onEmail() { uiaEmailPrompt.show(); } + function onFallbackAuth(fallback) { + var component = Qt.createComponent("qrc:/resources/qml/dialogs/FallbackAuthDialog.qml"); + if (component.status == Component.Ready) { + var dialog = component.createObject(timelineRoot, { + "fallback": fallback + }); + dialog.show(); + destroyOnClose(dialog); + } else { + console.error("Failed to create component: " + component.errorString()); + } + } function onPassword() { console.log("UIA: password needed"); uiaPassPrompt.show(); @@ -385,18 +396,6 @@ Pane { console.error("Failed to create component: " + component.errorString()); } } - function onFallbackAuth(fallback) { - var component = Qt.createComponent("qrc:/resources/qml/dialogs/FallbackAuthDialog.qml"); - if (component.status == Component.Ready) { - var dialog = component.createObject(timelineRoot, { - "fallback": fallback - }); - dialog.show(); - destroyOnClose(dialog); - } else { - console.error("Failed to create component: " + component.errorString()); - } - } target: UIA } diff --git a/resources/qml/TimelineBubbleMessageStyle.qml b/resources/qml/TimelineBubbleMessageStyle.qml new file mode 100644
index 00000000..2df3d917 --- /dev/null +++ b/resources/qml/TimelineBubbleMessageStyle.qml
@@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "./components" +import "./delegates" +import "./emoji" +import "./ui" +import "./dialogs" +import Qt.labs.platform 1.1 as Platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import im.nheko + +TimelineEvent { + id: wrapper + ListView.delayRemove: true + width: chat.delegateMaxWidth + height: Math.max((section.item?.height ?? 0) + gridContainer.implicitHeight + reactionRow.implicitHeight + unreadRow.height, 10) + anchors.horizontalCenter: ListView.view.contentItem.horizontalCenter + //room: chatRoot.roommodel + + required property var day + required property bool isSender + required property int index + property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day) + property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent) + property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId) + + required property date timestamp + required property string userId + required property string userName + required property string threadId + required property int userPowerlevel + required property bool isEdited + required property bool isEncrypted + required property var reactions + required property int status + required property int trustlevel + required property int notificationlevel + required property int type + required property bool isEditable + + required property QtObject messageContextMenu + required property QtObject replyContextMenu + required property Item messageActions + + property int avatarMargin: (wrapper.isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) // align bubble with section header + + property alias hovered: messageHover.hovered + property bool scrolledToThis: false + + mainInset: (threadId ? (4 + Nheko.paddingSmall) : 0) + 4 + replyInset: mainInset + 4 + Nheko.paddingSmall + + property int bubbleMargin: 40 + + maxWidth: chat.delegateMaxWidth - avatarMargin - bubbleMargin + + data: [ + Loader { + id: section + + active: wrapper.previousMessageUserId !== wrapper.userId || wrapper.previousMessageDay !== wrapper.day || wrapper.previousMessageIsStateEvent !== wrapper.isStateEvent + //asynchronous: true + sourceComponent: TimelineSectionHeader { + day: wrapper.day + isSender: wrapper.isSender + isStateEvent: wrapper.isStateEvent + parentWidth: wrapper.width + previousMessageDay: wrapper.previousMessageDay + previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent + previousMessageUserId: wrapper.previousMessageUserId + timestamp: wrapper.timestamp + userId: wrapper.userId + userName: wrapper.userName + userPowerlevel: wrapper.userPowerlevel + } + visible: status == Loader.Ready + z: 4 + }, + Rectangle { + anchors.fill: gridContainer + property color threadColor: TimelineManager.userColor(wrapper.threadId, palette.base) + property color threadBackgroundColor: wrapper.threadId ? Qt.tint(palette.base, Qt.hsla(threadColor.hslHue, 0.7, threadColor.hslLightness, 0.1)) : "transparent" + color: (Settings.messageHoverHighlight && messageHover.hovered) ? palette.alternateBase : threadBackgroundColor + + // this looks better without margins + TapHandler { + acceptedButtons: Qt.RightButton + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad + gesturePolicy: TapHandler.ReleaseWithinBounds + + onSingleTapped: messageContextMenu.show(wrapper.eventId, wrapper.threadId, wrapper.type, wrapper.isSender, wrapper.isEncrypted, wrapper.isEditable, wrapper.main.hoveredLink, wrapper.main.copyText) + } + }, + Rectangle { + id: scrollHighlight + anchors.fill: gridContainer + + color: palette.highlight + enabled: false + opacity: 0 + visible: true + z: 1 + + states: State { + name: "revealed" + when: wrapper.scrolledToThis + } + transitions: Transition { + from: "" + to: "revealed" + + SequentialAnimation { + PropertyAnimation { + duration: 500 + easing.type: Easing.InOutQuad + from: 0 + properties: "opacity" + target: scrollHighlight + to: 1 + } + PropertyAnimation { + duration: 500 + easing.type: Easing.InOutQuad + from: 1 + properties: "opacity" + target: scrollHighlight + to: 0 + } + ScriptAction { + script: wrapper.room.eventShown() + } + } + } + }, + Item { + id: gridContainer + + width: wrapper.width - wrapper.avatarMargin + implicitHeight: messageBubble.implicitHeight + x: wrapper.avatarMargin + y: section.visible && section.active ? section.y + section.height : 0 + + HoverHandler { + id: messageHover + blocking: false + onHoveredChanged: () => { + if (!Settings.mobileMode && hovered) { + if (!messageActions.hovered) { + messageActions.model = wrapper; + messageActions.attached = wrapper; + messageActions.anchors.bottomMargin = -gridContainer.y + //messageActions.anchors.rightMargin = metadata.width + } + } + } + + } + + + AbstractButton { + id: messageBubble + + anchors.left: (wrapper.isStateEvent || wrapper.isSender) ? undefined : parent.left + anchors.right: (wrapper.isStateEvent || !wrapper.isSender) ? undefined : parent.right + anchors.horizontalCenter: wrapper.isStateEvent ? parent.horizontalCenter : undefined + + property color userColor: TimelineManager.userColor(wrapper.main?.userId ?? '', palette.base) + + contentItem: Item { + id: contentPlacementContainer + + property bool fitsMetadata: ((wrapper.main?.width ?? 0) + wrapper.mainInset + metadata.width) < wrapper.maxWidth + + // This doesnt work because of tables. They might have content in the top of the cell, while the background reaches to the bottom. Maybe using the textDocument we could do more? + // property bool fitsMetadataInside: wrapper.main?.positionAt ? (wrapper.main.positionAt(wrapper.main.width, wrapper.main.height - 4) == wrapper.main.positionAt(wrapper.main.width - metadata.width, wrapper.main.height - 4)) : false + property bool fitsMetadataInside: false + + implicitWidth: Math.max((wrapper.reply?.width ?? 0) + wrapper.replyInset, (wrapper.main?.width ?? 0) + wrapper.mainInset + ((fitsMetadata && !fitsMetadataInside) ? metadata.width : 0)) + implicitHeight: contentColumn.implicitHeight + ((fitsMetadata || fitsMetadataInside) ? 0 : metadata.height) + + TimelineMetadata { + id: metadata + + scaling: 0.75 + + anchors.right: parent.right + anchors.bottom: parent.bottom + + visible: !wrapper.isStateEvent + + eventId: wrapper.eventId + status: wrapper.status + trustlevel: wrapper.trustlevel + isEdited: wrapper.isEdited + isEncrypted: wrapper.isEncrypted + threadId: wrapper.threadId + timestamp: wrapper.timestamp + room: wrapper.room + } + + Column { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + + AbstractButton { + id: replyRow + visible: wrapper.reply + + height: replyLine.height + anchors.left: parent.left + anchors.right: parent.right + + property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base) + + clip: true + + NhekoCursorShape { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + + contentItem: Row { + id: replyRowLay + + spacing: Nheko.paddingSmall + + Rectangle { + id: replyLine + height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height + color: replyRow.userColor + width: 4 + } + + Column { + spacing: 0 + + id: replyCol + + AbstractButton { + id: replyUserButton + + contentItem: Label { + id: userName_ + text: wrapper.reply?.userName ?? '' + color: replyRow.userColor + textFormat: Text.RichText + width: wrapper.maxWidth + //elideWidth: wrapper.maxWidth + } + onClicked: wrapper.room.openUserProfile(wrapper.reply?.userId) + } + data: [ + replyUserButton, + wrapper.reply, + ] + } + } + + background: Rectangle { + //width: replyRow.implicitContentWidth + color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1)) + } + + onClicked: { + let link = wrapper.reply.hoveredLink + if (link) { + Nheko.openLink(link) + } else { + console.log("Scrolling to "+wrapper.replyTo); + wrapper.room.showEvent(wrapper.replyTo) + } + } + onPressAndHold: wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(pressX-replyLine.width - Nheko.paddingSmall, pressY - replyUserButton.implicitHeight) : "", wrapper.replyTo) + TapHandler { + acceptedButtons: Qt.RightButton + onSingleTapped: (eventPoint) => wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(eventPoint.position.x-replyLine.width - Nheko.paddingSmall, eventPoint.position.y - replyUserButton.implicitHeight) : "", wrapper.replyTo) + gesturePolicy: TapHandler.ReleaseWithinBounds + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad + } + } + + data: [replyRow, wrapper.main] + } + } + + padding: wrapper.isStateEvent ? 0 : 4 + background: Rectangle { + color: !wrapper.isStateEvent ? Qt.tint(palette.base, Qt.hsla(messageBubble.userColor.hslHue, wrapper.hovered ? 0.8 : 0.5, messageBubble.userColor.hslLightness, 0.2)) : "transparent" + radius: 4 + border.color: Nheko.theme.red + border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0 + } + } + }, + Reactions { + id: reactionRow + + eventId: wrapper.eventId + layoutDirection: (!wrapper.isStateEvent && wrapper.isSender) ? Qt.RightToLeft : Qt.LeftToRight + reactions: wrapper.reactions + width: wrapper.width - wrapper.avatarMargin + x: wrapper.avatarMargin + + anchors { + //left: row.bubbleOnRight ? undefined : row.left + //right: row.bubbleOnRight ? row.right : undefined + top: gridContainer.bottom + topMargin: -4 + } + }, + Rectangle { + id: unreadRow + + color: palette.highlight + height: visible ? 3 : 0 + visible: (wrapper.index > 0 && (wrapper.room.fullyReadEventId == wrapper.eventId)) + + anchors { + left: parent.left + right: parent.right + top: reactionRow.bottom + topMargin: 5 + } + } + ] +} + diff --git a/resources/qml/TimelineDefaultMessageStyle.qml b/resources/qml/TimelineDefaultMessageStyle.qml new file mode 100644
index 00000000..e9a0712d --- /dev/null +++ b/resources/qml/TimelineDefaultMessageStyle.qml
@@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "./components" +import "./delegates" +import "./emoji" +import "./ui" +import "./dialogs" +import Qt.labs.platform 1.1 as Platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import im.nheko + +TimelineEvent { + id: wrapper + ListView.delayRemove: true + width: chat.delegateMaxWidth + height: Math.max((section.item?.height ?? 0) + gridContainer.implicitHeight + reactionRow.implicitHeight + unreadRow.height, 10) + anchors.horizontalCenter: ListView.view.contentItem.horizontalCenter + //room: chatRoot.roommodel + + required property var day + required property bool isSender + required property int index + property var previousMessageDay: (index + 1) >= chat.count ? 0 : chat.model.dataByIndex(index + 1, Room.Day) + property bool previousMessageIsStateEvent: (index + 1) >= chat.count ? true : chat.model.dataByIndex(index + 1, Room.IsStateEvent) + property string previousMessageUserId: (index + 1) >= chat.count ? "" : chat.model.dataByIndex(index + 1, Room.UserId) + + required property date timestamp + required property string userId + required property string userName + required property string threadId + required property int userPowerlevel + required property bool isEdited + required property bool isEncrypted + required property var reactions + required property int status + required property int trustlevel + required property int notificationlevel + required property int type + required property bool isEditable + + required property QtObject messageContextMenu + required property QtObject replyContextMenu + required property Item messageActions + + property int avatarMargin: (wrapper.isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) // align bubble with section header + + property alias hovered: messageHover.hovered + property bool scrolledToThis: false + + mainInset: (threadId ? (4 + Nheko.paddingSmall) : 0) + replyInset: mainInset + 4 + Nheko.paddingSmall + + maxWidth: chat.delegateMaxWidth - avatarMargin - metadata.width + + data: [ + Loader { + id: section + + active: wrapper.previousMessageUserId !== wrapper.userId || wrapper.previousMessageDay !== wrapper.day || wrapper.previousMessageIsStateEvent !== wrapper.isStateEvent + //asynchronous: true + sourceComponent: TimelineSectionHeader { + day: wrapper.day + isSender: wrapper.isSender + isStateEvent: wrapper.isStateEvent + parentWidth: wrapper.width + previousMessageDay: wrapper.previousMessageDay + previousMessageIsStateEvent: wrapper.previousMessageIsStateEvent + previousMessageUserId: wrapper.previousMessageUserId + timestamp: wrapper.timestamp + userId: wrapper.userId + userName: wrapper.userName + userPowerlevel: wrapper.userPowerlevel + } + visible: status == Loader.Ready + z: 4 + }, + Rectangle { + anchors.fill: gridContainer + color: (Settings.messageHoverHighlight && messageHover.hovered) ? palette.alternateBase : "transparent" + + // this looks better without margins + TapHandler { + acceptedButtons: Qt.RightButton + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad + gesturePolicy: TapHandler.ReleaseWithinBounds + + onSingleTapped: messageContextMenu.show(wrapper.eventId, wrapper.threadId, wrapper.type, wrapper.isSender, wrapper.isEncrypted, wrapper.isEditable, wrapper.main.hoveredLink, wrapper.main.copyText) + } + }, + Rectangle { + id: scrollHighlight + anchors.fill: gridContainer + + color: palette.highlight + enabled: false + opacity: 0 + visible: true + z: 1 + + states: State { + name: "revealed" + when: wrapper.scrolledToThis + } + transitions: Transition { + from: "" + to: "revealed" + + SequentialAnimation { + PropertyAnimation { + duration: 500 + easing.type: Easing.InOutQuad + from: 0 + properties: "opacity" + target: scrollHighlight + to: 1 + } + PropertyAnimation { + duration: 500 + easing.type: Easing.InOutQuad + from: 1 + properties: "opacity" + target: scrollHighlight + to: 0 + } + ScriptAction { + script: wrapper.room.eventShown() + } + } + } + }, + Rectangle { + anchors.top: gridContainer.top + anchors.left: gridContainer.left + anchors.topMargin: -2 + anchors.leftMargin: -2 + color: "transparent" + border.color: Nheko.theme.red + border.width: wrapper.notificationlevel == MtxEvent.Highlight ? 1 : 0 + radius: 4 + height: contentColumn.implicitHeight + 4 + width: contentColumn.implicitWidth + 4 + }, + Row { + id: gridContainer + + width: wrapper.width - wrapper.avatarMargin + x: wrapper.avatarMargin + y: section.visible && section.active ? section.y + section.height : 0 + spacing: Nheko.paddingSmall + + HoverHandler { + id: messageHover + blocking: false + onHoveredChanged: () => { + if (!Settings.mobileMode && hovered) { + if (!messageActions.hovered) { + messageActions.model = wrapper; + messageActions.attached = wrapper; + messageActions.anchors.bottomMargin = -gridContainer.y + messageActions.anchors.rightMargin = metadata.width + } + } + } + + } + + AbstractButton { + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Part of a thread") + ToolTip.visible: hovered + height: contentColumn.height + visible: wrapper.threadId + width: 4 + + onClicked: wrapper.room.thread = wrapper.threadId + + Rectangle { + id: threadLine + + anchors.fill: parent + color: TimelineManager.userColor(wrapper.threadId, palette.base) + } + } + + Item { + visible: wrapper.isStateEvent + width: (wrapper.maxWidth - (wrapper.main?.width ?? 0)) / 2 + height: 1 + } + + Column { + id: contentColumn + + AbstractButton { + id: replyRow + visible: wrapper.reply + + height: replyLine.height + + property color userColor: TimelineManager.userColor(wrapper.reply?.userId ?? '', palette.base) + + clip: true + + NhekoCursorShape { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + + contentItem: Row { + id: replyRowLay + + spacing: Nheko.paddingSmall + + Rectangle { + id: replyLine + height: Math.min( wrapper.reply?.height, timelineView.height / 10) + Nheko.paddingSmall + replyUserButton.height + color: replyRow.userColor + width: 4 + } + + Column { + spacing: 0 + + id: replyCol + + AbstractButton { + id: replyUserButton + + contentItem: Label { + id: userName_ + text: wrapper.reply?.userName ?? '' + color: replyRow.userColor + textFormat: Text.RichText + width: wrapper.maxWidth + //elideWidth: wrapper.maxWidth + } + onClicked: wrapper.room.openUserProfile(wrapper.reply?.userId) + } + data: [ + replyUserButton, + wrapper.reply, + ] + } + } + + background: Rectangle { + //width: replyRow.implicitContentWidth + color: Qt.tint(palette.base, Qt.hsla(replyRow.userColor.hslHue, 0.5, replyRow.userColor.hslLightness, 0.1)) + } + + onClicked: { + let link = wrapper.reply.hoveredLink + if (link) { + Nheko.openLink(link) + } else { + console.log("Scrolling to "+wrapper.replyTo); + wrapper.room.showEvent(wrapper.replyTo) + } + } + onPressAndHold: wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(pressX-replyLine.width - Nheko.paddingSmall, pressY - replyUserButton.implicitHeight) : "", wrapper.replyTo) + TapHandler { + acceptedButtons: Qt.RightButton + onSingleTapped: (eventPoint) => wrapper.replyContextMenu.show(wrapper.reply.copyText ?? "", wrapper.reply.linkAt ? wrapper.reply.linkAt(eventPoint.position.x-replyLine.width - Nheko.paddingSmall, eventPoint.position.y - replyUserButton.implicitHeight) : "", wrapper.replyTo) + gesturePolicy: TapHandler.ReleaseWithinBounds + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad + } + } + + data: [ + replyRow, wrapper.main, + ] + } + + }, + TimelineMetadata { + id: metadata + + scaling: 1 + + anchors.right: parent.right + y: section.visible && section.active ? section.y + section.height : 0 + + visible: !wrapper.isStateEvent + + eventId: wrapper.eventId + status: wrapper.status + trustlevel: wrapper.trustlevel + isEdited: wrapper.isEdited + isEncrypted: wrapper.isEncrypted + threadId: wrapper.threadId + timestamp: wrapper.timestamp + room: wrapper.room + }, + Reactions { + id: reactionRow + + eventId: wrapper.eventId + reactions: wrapper.reactions + width: wrapper.width - wrapper.avatarMargin + x: wrapper.avatarMargin + + anchors { + top: gridContainer.bottom + topMargin: -4 + } + }, + Rectangle { + id: unreadRow + + color: palette.highlight + height: visible ? 3 : 0 + visible: (wrapper.index > 0 && (wrapper.room.fullyReadEventId == wrapper.eventId)) + + anchors { + left: parent.left + right: parent.right + top: reactionRow.bottom + topMargin: 5 + } + } + ] +} diff --git a/resources/qml/TimelineEvent.qml b/resources/qml/TimelineEvent.qml new file mode 100644
index 00000000..ed6eb87e --- /dev/null +++ b/resources/qml/TimelineEvent.qml
@@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "./components" +import "./delegates" +import "./emoji" +import "./ui" +import "./dialogs" +import Qt.labs.platform 1.1 as Platform +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.2 +import QtQuick.Window 2.13 +import im.nheko 1.0 + +EventDelegateChooser { + id: wrapper + + required property bool isStateEvent + + EventDelegateChoice { + roleValues: [MtxEvent.TextMessage, MtxEvent.NoticeMessage, MtxEvent.ElementEffectMessage, MtxEvent.UnknownMessage,] + + TextMessage { + required property string formattedBody + required property int type + required property string userId + required property string userName + + Layout.fillWidth: true + //Layout.maximumWidth: implicitWidth + + color: type == MtxEvent.NoticeMessage ? palette.active.buttonText : palette.active.text + font.italic: type == MtxEvent.NoticeMessage + formatted: formattedBody + keepFullText: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.EmoteMessage,] + + TextMessage { + required property string formattedBody + required property string userId + required property string userName + + Layout.fillWidth: true + //Layout.maximumWidth: implicitWidth + + color: TimelineManager.userColor(userId, palette.active.base) + font.italic: true + formatted: TimelineManager.escapeEmoji(userName) + " " + formattedBody + keepFullText: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.CanonicalAlias, MtxEvent.ServerAcl, MtxEvent.Name, MtxEvent.Topic, MtxEvent.Avatar, MtxEvent.PinnedEvents, MtxEvent.ImagePackInRoom, MtxEvent.SpaceParent, MtxEvent.RoomCreate, MtxEvent.PowerLevels, MtxEvent.PolicyRuleUser, MtxEvent.PolicyRuleRoom, MtxEvent.PolicyRuleServer, MtxEvent.RoomJoinRules, MtxEvent.RoomHistoryVisibility, MtxEvent.RoomGuestAccess,] + + TextMessage { + required property string formattedStateEvent + required property string userId + required property string userName + + body: '' + color: palette.active.buttonText + font.italic: true + font.pointSize: Settings.fontSize * 0.8 + formatted: '' + horizontalAlignment: Text.AlignHCenter + isOnlyEmoji: false + keepFullText: true + text: formattedStateEvent + } + } + EventDelegateChoice { + roleValues: [MtxEvent.CallInvite,] + + TextMessage { + required property string callType + required property string userId + required property string userName + + Layout.fillWidth: true + body: formatted + color: palette.active.buttonText + font.italic: true + formatted: { + switch (callType) { + case "voice": + return qsTr("%1 placed a voice call.").arg(TimelineManager.escapeEmoji(userName)); + case "video": + return qsTr("%1 placed a video call.").arg(TimelineManager.escapeEmoji(userName)); + default: + return qsTr("%1 placed a call.").arg(TimelineManager.escapeEmoji(userName)); + } + } + isOnlyEmoji: false + keepFullText: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.CallAnswer, MtxEvent.CallReject, MtxEvent.CallSelectAnswer, MtxEvent.CallHangUp, MtxEvent.CallCandidates, MtxEvent.CallNegotiate,] + + TextMessage { + required property int type + required property string userId + required property string userName + + Layout.fillWidth: true + body: formatted + color: palette.active.buttonText + font.italic: true + formatted: { + switch (type) { + case MtxEvent.CallAnswer: + return qsTr("%1 answered the call.").arg(TimelineManager.escapeEmoji(userName)); + case MtxEvent.CallReject: + return qsTr("%1 rejected the call.").arg(TimelineManager.escapeEmoji(userName)); + case MtxEvent.CallSelectAnswer: + return qsTr("%1 selected answer.").arg(TimelineManager.escapeEmoji(userName)); + case MtxEvent.CallHangUp: + return qsTr("%1 ended the call.").arg(TimelineManager.escapeEmoji(userName)); + case MtxEvent.CallCandidates: + return qsTr("%1 is negotiating the call...").arg(TimelineManager.escapeEmoji(userName)); + case MtxEvent.CallNegotiate: + return qsTr("%1 is negotiating the call...").arg(TimelineManager.escapeEmoji(userName)); + } + } + isOnlyEmoji: false + keepFullText: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.ImageMessage, MtxEvent.Sticker,] + + ImageMessage { + required property string userId + required property string userName + + Layout.fillWidth: true + //Layout.maximumWidth: tempWidth + //Layout.maximumHeight: timelineView.height / 8 + containerHeight: timelineView.height + } + } + EventDelegateChoice { + roleValues: [MtxEvent.FileMessage,] + + FileMessage { + required property string userId + required property string userName + + Layout.fillWidth: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.VideoMessage, MtxEvent.AudioMessage,] + + PlayableMediaMessage { + required property string userId + required property string userName + + Layout.fillWidth: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.Encrypted,] + + Encrypted { + required property string userId + required property string userName + + Layout.fillWidth: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.Encryption,] + + EncryptionEnabled { + required property string userId + + Layout.fillWidth: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.Redacted] + + Redacted { + required property string userId + required property string userName + + Layout.fillWidth: true + } + } + EventDelegateChoice { + roleValues: [MtxEvent.Member] + + ColumnLayout { + id: member + + required property string formattedStateEvent + required property Room room + required property string userId + required property string userName + + NoticeMessage { + Layout.fillWidth: true + body: formatted + formatted: member.formattedStateEvent + isOnlyEmoji: false + isReply: EventDelegateChooser.isReply + isStateEvent: true + keepFullText: true + } + Button { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Allow them in") + visible: member.room.showAcceptKnockButton(member.eventId) + + onClicked: member.room.acceptKnock(member.eventId) + } + } + } + EventDelegateChoice { + roleValues: [MtxEvent.Tombstone] + + ColumnLayout { + id: tombstone + + required property string body + required property string eventId + required property Room room + required property string userId + required property string userName + + NoticeMessage { + Layout.fillWidth: true + body: formatted + formatted: qsTr("This room was replaced for the following reason: %1").arg(tombstone.body) + isOnlyEmoji: false + isReply: EventDelegateChooser.isReply + isStateEvent: true + keepFullText: true + } + Button { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Go to replacement room") + + onClicked: tombstone.room.joinReplacementRoom(tombstone.eventId) + } + } + } + EventDelegateChoice { + roleValues: [] + + MatrixText { + required property string typeString + required property string userId + required property string userName + + Layout.fillWidth: true + text: "Unsupported: " + typeString + } + } +} diff --git a/resources/qml/TimelineMetadata.qml b/resources/qml/TimelineMetadata.qml new file mode 100644
index 00000000..d57c56d9 --- /dev/null +++ b/resources/qml/TimelineMetadata.qml
@@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "./components" +import "./delegates" +import "./emoji" +import "./ui" +import "./dialogs" +import Qt.labs.platform 1.1 as Platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import im.nheko + +RowLayout { + id: metadata + + property int iconSize: Math.floor(fontMetrics.ascent * scaling) + required property double scaling + + required property string eventId + required property int status + required property int trustlevel + required property bool isEdited + required property bool isEncrypted + required property string threadId + required property date timestamp + required property Room room + + spacing: 2 + + StatusIndicator { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + eventId: metadata.eventId + height: parent.iconSize + status: metadata.status + width: parent.iconSize + } + Image { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Edited") + ToolTip.visible: editHovered.hovered + height: parent.iconSize + source: "image://colorimage/:/icons/icons/ui/edit.svg?" + ((metadata.eventId == metadata.room.edit) ? palette.highlight : palette.buttonText) + sourceSize.height: parent.iconSize * Screen.devicePixelRatio + sourceSize.width: parent.iconSize * Screen.devicePixelRatio + visible: metadata.isEdited || metadata.eventId == metadata.room.edit + width: parent.iconSize + Layout.preferredWidth: parent.iconSize + Layout.preferredHeight: parent.iconSize + HoverHandler { + id: editHovered + + } + } + ImageButton { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Part of a thread") + ToolTip.visible: hovered + buttonTextColor: TimelineManager.userColor(metadata.threadId, palette.base) + height: parent.iconSize + image: ":/icons/icons/ui/thread.svg" + visible: metadata.threadId + width: parent.iconSize + + onClicked: metadata.room.thread = threadId + } + EncryptionIndicator { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + encrypted: metadata.isEncrypted + height: parent.iconSize + sourceSize.height: parent.iconSize * Screen.devicePixelRatio + sourceSize.width: parent.iconSize * Screen.devicePixelRatio + trust: metadata.trustlevel + visible: metadata.room.isEncrypted + width: parent.iconSize + Layout.preferredWidth: parent.iconSize + Layout.preferredHeight: parent.iconSize + } + Label { + id: ts + + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredWidth: implicitWidth + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: Qt.formatDateTime(metadata.timestamp, Qt.DefaultLocaleLongDate) + ToolTip.visible: ma.hovered + color: palette.inactive.text + font.pointSize: fontMetrics.font.pointSize * parent.scaling + text: metadata.timestamp.toLocaleTimeString(Locale.ShortFormat) + + HoverHandler { + id: ma + + } + } +} diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml deleted file mode 100644
index 16a31a3c..00000000 --- a/resources/qml/TimelineRow.qml +++ /dev/null
@@ -1,349 +0,0 @@ -// SPDX-FileCopyrightText: Nheko Contributors -// -// SPDX-License-Identifier: GPL-3.0-or-later - -import "./delegates" -import "./emoji" -import QtQuick 2.15 -import QtQuick.Controls 2.3 -import QtQuick.Layouts 1.2 -import QtQuick.Window 2.13 -import im.nheko 1.0 - -AbstractButton { - id: r - - required property string blurhash - required property string body - required property string callType - required property int duration - required property int encryptionError - required property string eventId - required property string filename - required property string filesize - required property string formattedBody - required property int index - required property bool isEditable - required property bool isEdited - required property bool isEncrypted - required property bool isOnlyEmoji - required property bool isSender - required property bool isStateEvent - required property int notificationlevel - required property int originalWidth - required property double proportionalHeight - required property var reactions - required property int relatedEventCacheBuster - required property string replyTo - required property string roomName - required property string roomTopic - required property int status - required property string threadId - required property string thumbnailUrl - required property var timestamp - required property int trustlevel - required property int type - required property string typeString - required property string url - required property string userId - required property string userName - - height: row.height + (reactionRow.height > 0 ? reactionRow.height - 2 : 0) + unreadRow.height - hoverEnabled: true - - states: State { - name: "dragging" - when: draghandler.active - } - transitions: Transition { - from: "dragging" - to: "" - - PropertyAnimation { - duration: 100 - easing.type: Easing.InOutQuad - properties: "x" - target: r - to: 0 - } - } - - onClicked: { - let link = contentItem.child.linkAt != undefined && contentItem.child.linkAt(pressX - row.x - msg.x, pressY - row.y - msg.y - contentItem.y); - if (link) { - Nheko.openLink(link); - } - } - onDoubleClicked: room.reply = eventId - onPressAndHold: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) - - Rectangle { - anchors.fill: parent - color: (Settings.messageHoverHighlight && hovered) ? palette.alternateBase : "transparent" - - // this looks better without margins - TapHandler { - acceptedButtons: Qt.RightButton - acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad - gesturePolicy: TapHandler.ReleaseWithinBounds - - onSingleTapped: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) - } - } - DragHandler { - id: draghandler - - xAxis.maximum: 100 - xAxis.minimum: -100 - yAxis.enabled: false - - onActiveChanged: { - if (!active && (x < -70 || x > 70)) - room.reply = eventId; - } - } - AbstractButton { - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Part of a thread") - ToolTip.visible: hovered - anchors.left: parent.left - anchors.leftMargin: Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8) // align bubble with section header - height: parent.height - visible: threadId - width: 4 - - onClicked: room.thread = threadId - - Rectangle { - id: threadLine - - anchors.fill: parent - color: TimelineManager.userColor(threadId, palette.base) - } - } - Rectangle { - id: row - - property color bgColor: palette.base - property bool bubbleOnRight: isSender && Settings.bubbles - property int maxWidth: (parent.width - (Settings.smallAvatars || isStateEvent ? 0 : Nheko.avatarSize + 8)) * (Settings.bubbles && !isStateEvent ? 0.9 : 1) - property color userColor: TimelineManager.userColor(userId, palette.base) - - anchors.horizontalCenter: isStateEvent ? parent.horizontalCenter : undefined - anchors.left: (isStateEvent || bubbleOnRight) ? undefined : parent.left - anchors.leftMargin: (isStateEvent || Settings.smallAvatars ? 0 : (Nheko.avatarSize + 8)) + (threadId ? 6 : 0) // align bubble with section header - anchors.right: (isStateEvent || !bubbleOnRight) ? undefined : parent.right - border.color: Nheko.theme.red - border.width: r.notificationlevel == MtxEvent.Highlight ? 1 : 0 - color: (Settings.bubbles && !isStateEvent) ? Qt.tint(bgColor, Qt.hsla(userColor.hslHue, 0.5, userColor.hslLightness, 0.2)) : "#00000000" - height: msg.height + msg.anchors.margins * 2 - radius: 4 - width: Settings.bubbles ? Math.min(maxWidth, Math.max(reply.implicitWidth + 8, contentItem.implicitWidth + metadata.width + 20)) : maxWidth - - GridLayout { - id: msg - - columnSpacing: 2 - columns: Settings.bubbles ? 1 : 2 - rowSpacing: 0 - rows: Settings.bubbles ? 3 : 2 - - anchors { - left: parent.left - leftMargin: 4 - margins: (Settings.bubbles && !isStateEvent) ? 4 : 2 - right: parent.right - rightMargin: 4 - top: parent.top - } - - // fancy reply, if this is a reply - Reply { - id: reply - - function fromModel(role) { - return replyTo != "" ? room.dataById(replyTo, role, r.eventId) : null; - } - - Layout.bottomMargin: visible ? 2 : 0 - Layout.column: 0 - Layout.fillWidth: true - Layout.maximumWidth: Settings.bubbles ? Number.MAX_VALUE : implicitWidth - Layout.preferredHeight: height - Layout.row: 0 - blurhash: r.relatedEventCacheBuster, fromModel(Room.Blurhash) ?? "" - body: r.relatedEventCacheBuster, fromModel(Room.Body) ?? "" - callType: r.relatedEventCacheBuster, fromModel(Room.Voip) ?? "" - duration: r.relatedEventCacheBuster, fromModel(Room.Duration) ?? 0 - encryptionError: r.relatedEventCacheBuster, fromModel(Room.EncryptionError) ?? 0 - eventId: fromModel(Room.EventId) ?? "" - filename: r.relatedEventCacheBuster, fromModel(Room.Filename) ?? "" - filesize: r.relatedEventCacheBuster, fromModel(Room.Filesize) ?? "" - formattedBody: r.relatedEventCacheBuster, fromModel(Room.FormattedBody) ?? "" - isOnlyEmoji: r.relatedEventCacheBuster, fromModel(Room.IsOnlyEmoji) ?? false - isStateEvent: r.relatedEventCacheBuster, fromModel(Room.IsStateEvent) ?? false - originalWidth: r.relatedEventCacheBuster, fromModel(Room.OriginalWidth) ?? 0 - proportionalHeight: r.relatedEventCacheBuster, fromModel(Room.ProportionalHeight) ?? 1 - relatedEventCacheBuster: r.relatedEventCacheBuster, fromModel(Room.RelatedEventCacheBuster) ?? 0 - roomName: r.relatedEventCacheBuster, fromModel(Room.RoomName) ?? "" - roomTopic: r.relatedEventCacheBuster, fromModel(Room.RoomTopic) ?? "" - thumbnailUrl: r.relatedEventCacheBuster, fromModel(Room.ThumbnailUrl) ?? "" - type: r.relatedEventCacheBuster, fromModel(Room.Type) ?? MtxEvent.UnknownMessage - typeString: r.relatedEventCacheBuster, fromModel(Room.TypeString) ?? "" - url: r.relatedEventCacheBuster, fromModel(Room.Url) ?? "" - userColor: r.relatedEventCacheBuster, TimelineManager.userColor(userId, palette.base) - userId: r.relatedEventCacheBuster, fromModel(Room.UserId) ?? "" - userName: r.relatedEventCacheBuster, fromModel(Room.UserName) ?? "" - visible: replyTo - } - - // actual message content - MessageDelegate { - id: contentItem - - Layout.column: 0 - Layout.fillWidth: true - Layout.preferredHeight: height - Layout.row: 1 - blurhash: r.blurhash - body: r.body - callType: r.callType - duration: r.duration - encryptionError: r.encryptionError - eventId: r.eventId - filename: r.filename - filesize: r.filesize - formattedBody: r.formattedBody - isOnlyEmoji: r.isOnlyEmoji - isReply: false - isStateEvent: r.isStateEvent - metadataWidth: metadata.width - originalWidth: r.originalWidth - proportionalHeight: r.proportionalHeight - relatedEventCacheBuster: r.relatedEventCacheBuster - roomName: r.roomName - roomTopic: r.roomTopic - thumbnailUrl: r.thumbnailUrl - type: r.type - typeString: r.typeString ?? "" - url: r.url - userId: r.userId - userName: r.userName - } - Row { - id: metadata - - property int iconSize: Math.floor(fontMetrics.ascent * scaling) - property double scaling: Settings.bubbles ? 0.75 : 1 - - Layout.alignment: Qt.AlignTop | Qt.AlignRight - Layout.bottomMargin: -2 - Layout.column: Settings.bubbles ? 0 : 1 - Layout.preferredWidth: implicitWidth - Layout.row: Settings.bubbles ? 2 : 0 - Layout.rowSpan: Settings.bubbles ? 1 : 2 - Layout.topMargin: (contentItem.fitsMetadata && Settings.bubbles) ? -height - Layout.bottomMargin : 0 - spacing: 2 - visible: !isStateEvent - - StatusIndicator { - Layout.alignment: Qt.AlignRight | Qt.AlignTop - anchors.verticalCenter: ts.verticalCenter - eventId: r.eventId - height: parent.iconSize - status: r.status - width: parent.iconSize - } - Image { - Layout.alignment: Qt.AlignRight | Qt.AlignTop - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Edited") - ToolTip.visible: editHovered.hovered - anchors.verticalCenter: ts.verticalCenter - height: parent.iconSize - source: "image://colorimage/:/icons/icons/ui/edit.svg?" + ((eventId == room.edit) ? palette.highlight : palette.buttonText) - sourceSize.height: parent.iconSize * Screen.devicePixelRatio - sourceSize.width: parent.iconSize * Screen.devicePixelRatio - visible: isEdited || eventId == room.edit - width: parent.iconSize - - HoverHandler { - id: editHovered - - } - } - ImageButton { - Layout.alignment: Qt.AlignRight | Qt.AlignTop - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: qsTr("Part of a thread") - ToolTip.visible: hovered - anchors.verticalCenter: ts.verticalCenter - buttonTextColor: TimelineManager.userColor(threadId, palette.base) - height: parent.iconSize - image: ":/icons/icons/ui/thread.svg" - visible: threadId - width: parent.iconSize - - onClicked: room.thread = threadId - } - EncryptionIndicator { - Layout.alignment: Qt.AlignRight | Qt.AlignTop - anchors.verticalCenter: ts.verticalCenter - encrypted: isEncrypted - height: parent.iconSize - sourceSize.height: parent.iconSize * Screen.devicePixelRatio - sourceSize.width: parent.iconSize * Screen.devicePixelRatio - trust: trustlevel - visible: room.isEncrypted - width: parent.iconSize - } - Label { - id: ts - - Layout.alignment: Qt.AlignRight | Qt.AlignTop - Layout.preferredWidth: implicitWidth - ToolTip.delay: Nheko.tooltipDelay - ToolTip.text: Qt.formatDateTime(timestamp, Qt.DefaultLocaleLongDate) - ToolTip.visible: ma.hovered - color: palette.inactive.text - font.pointSize: fontMetrics.font.pointSize * parent.scaling - text: timestamp.toLocaleTimeString(Locale.ShortFormat) - - HoverHandler { - id: ma - - } - } - } - } - } - Reactions { - id: reactionRow - - eventId: r.eventId - layoutDirection: row.bubbleOnRight ? Qt.RightToLeft : Qt.LeftToRight - reactions: r.reactions - width: row.maxWidth - - anchors { - left: row.bubbleOnRight ? undefined : row.left - right: row.bubbleOnRight ? row.right : undefined - top: row.bottom - topMargin: -4 - } - } - Rectangle { - id: unreadRow - - color: palette.highlight - height: visible ? 3 : 0 - visible: (r.index > 0 && (room.fullyReadEventId == r.eventId)) - - anchors { - left: parent.left - right: parent.right - top: reactionRow.bottom - topMargin: 5 - } - } -} diff --git a/resources/qml/TimelineSectionHeader.qml b/resources/qml/TimelineSectionHeader.qml new file mode 100644
index 00000000..9376d8f5 --- /dev/null +++ b/resources/qml/TimelineSectionHeader.qml
@@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import Qt.labs.platform as Platform +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import im.nheko + +import "./components" + +Column { + + required property var day + required property bool isSender + required property bool isStateEvent + required property int parentWidth + required property var previousMessageDay + required property bool previousMessageIsStateEvent + required property string previousMessageUserId + required property date timestamp + required property string userId + required property string userName + required property string userPowerlevel + + bottomPadding: Settings.bubbles ? (isSender && previousMessageDay == day ? 0 : 2) : 3 + spacing: 8 + topPadding: userName_.visible ? 4 : 0 + visible: (previousMessageUserId !== userId || previousMessageDay !== day || isStateEvent !== previousMessageIsStateEvent) + width: parentWidth + + Label { + id: dateBubble + + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined + color: palette.text + height: Math.round(fontMetrics.height * 1.4) + horizontalAlignment: Text.AlignHCenter + text: room ? room.formatDateSeparator(timestamp) : "" + verticalAlignment: Text.AlignVCenter + visible: room && previousMessageDay !== day + width: contentWidth * 1.2 + + background: Rectangle { + color: palette.window + radius: parent.height / 2 + } + } + Row { + id: userInfo + + property int remainingWidth: chat.delegateMaxWidth - spacing - messageUserAvatar.width + + height: userName_.height + spacing: 8 + visible: !isStateEvent && (!isSender || !Settings.bubbles) + + Avatar { + id: messageUserAvatar + + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: userid + ToolTip.visible: messageUserAvatar.hovered + displayName: userName + height: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) + url: !room ? "" : room.avatarUrl(userId).replace("mxc://", "image://MxcImage/") + userid: userId + width: Nheko.avatarSize * (Settings.smallAvatars ? 0.5 : 1) + + onClicked: room.openUserProfile(userId) + } + Connections { + function onRoomAvatarUrlChanged() { + messageUserAvatar.url = room.avatarUrl(userId).replace("mxc://", "image://MxcImage/"); + } + function onScrollToIndex(index) { + chat.positionViewAtIndex(index, ListView.Center); + } + + target: room + } + + AbstractButton { + id: userNameButton + + PowerlevelIndicator { + id: powerlevelIndicator + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + powerlevel: userPowerlevel + height: fontMetrics.ascent + width: height + + sourceSize.width: fontMetrics.lineSpacing + sourceSize.height: fontMetrics.lineSpacing + + permissions: room ? room.permissions : null + visible: isAdmin || isModerator + } + + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: userId + ToolTip.visible: hovered + leftPadding: powerlevelIndicator.visible ? 16 : 0 + leftInset: 0 + rightInset: 0 + rightPadding: 0 + + contentItem: Label { + id: userName_ + + color: TimelineManager.userColor(userId, palette.base) + text: TimelineManager.escapeEmoji(userNameTextMetrics.elidedText) + textFormat: Text.RichText + } + + onClicked: room.openUserProfile(userId) + + TextMetrics { + id: userNameTextMetrics + + elide: Text.ElideRight + elideWidth: userInfo.remainingWidth - Math.min(statusMsg.implicitWidth, userInfo.remainingWidth / 3) + text: userName + } + NhekoCursorShape { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + } + Label { + id: statusMsg + + property string userStatus: Presence.userStatus(userId) + + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("%1's status message").arg(userName) + ToolTip.visible: statusMsgHoverHandler.hovered + anchors.baseline: userNameButton.baseline + color: palette.buttonText + elide: Text.ElideRight + font.italic: true + font.pointSize: Math.floor(fontMetrics.font.pointSize * 0.8) + text: userStatus.replace(/\n/g, " ") + textFormat: Text.PlainText + width: Math.min(implicitWidth, userInfo.remainingWidth - userName_.width - parent.spacing) + + HoverHandler { + id: statusMsgHoverHandler + + } + Connections { + function onPresenceChanged(id) { + if (id == userId) + statusMsg.userStatus = Presence.userStatus(userId); + } + + target: Presence + } + } + } +} + diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index bbcf2366..8db866df 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml
@@ -381,7 +381,10 @@ Item { repeat: false running: false - onTriggered: shouldEffectsRun = false + onTriggered: { + timelineEffects.removeParticles() + shouldEffectsRun = false + } } Connections { function onConfetti() { diff --git a/resources/qml/TopBar.qml b/resources/qml/TopBar.qml
index 699595e6..b7cd3ff0 100644 --- a/resources/qml/TopBar.qml +++ b/resources/qml/TopBar.qml
@@ -125,6 +125,8 @@ Pane { Layout.row: 2 clip: true enabled: false + // don't use the disabled color + color: topBar.palette.text selectByMouse: false text: roomTopic } @@ -285,25 +287,10 @@ Pane { property var e: room ? room.getDump(modelData, "pins") : {} - Layout.fillWidth: true - Layout.preferredHeight: height - blurhash: e.blurhash ?? "" - body: e.body ?? "" - encryptionError: e.encryptionError ?? 0 + maxWidth: pinnedMessages.width + //Layout.preferredHeight: height eventId: e.eventId ?? "" - filename: e.filename ?? "" - filesize: e.filesize ?? "" - formattedBody: e.formattedBody ?? "" - isOnlyEmoji: e.isOnlyEmoji ?? false - keepFullText: true - originalWidth: e.originalWidth ?? 0 - proportionalHeight: e.proportionalHeight ?? 1 - type: e.type ?? MtxEvent.UnknownMessage - typeString: e.typeString ?? "" - url: e.url ?? "" userColor: TimelineManager.userColor(e.userId, palette.window) - userId: e.userId ?? "" - userName: e.userName ?? "" Connections { function onPinnedMessagesChanged() { diff --git a/resources/qml/components/PowerlevelIndicator.qml b/resources/qml/components/PowerlevelIndicator.qml
index 4b826284..6a6d89af 100644 --- a/resources/qml/components/PowerlevelIndicator.qml +++ b/resources/qml/components/PowerlevelIndicator.qml
@@ -23,8 +23,6 @@ Image { return "image://colorimage/:/icons/icons/ui/person.svg?"; } - sourceSize.width: 16 - sourceSize.height: 16 source: sourceUrl + (ma.hovered ? palette.highlight : palette.buttonText) ToolTip.visible: ma.hovered ToolTip.text: { diff --git a/resources/qml/delegates/Encrypted.qml b/resources/qml/delegates/Encrypted.qml
index fdfe958e..7aeeb28a 100644 --- a/resources/qml/delegates/Encrypted.qml +++ b/resources/qml/delegates/Encrypted.qml
@@ -8,37 +8,34 @@ import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import im.nheko 1.0 -Rectangle { +Control { id: r required property int encryptionError required property string eventId - radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium - width: parent.width? parent.width : 0 - implicitWidth: encryptedText.implicitWidth+24+Nheko.paddingMedium*3 // Column doesn't provide a useful implicitWidth, should be replaced by ColumnLayout - height: contents.implicitHeight + Nheko.paddingMedium * 2 - color: palette.alternateBase + padding: Nheko.paddingMedium + implicitHeight: contents.implicitHeight + Nheko.paddingMedium * 2 + Layout.maximumWidth: contents.Layout.maximumWidth + padding * 2 + Layout.fillWidth: true - RowLayout { + contentItem: RowLayout { id: contents - anchors.fill: parent - anchors.margins: Nheko.paddingMedium spacing: Nheko.paddingMedium Image { source: "image://colorimage/:/icons/icons/ui/shield-filled-cross.svg?" + Nheko.theme.error Layout.alignment: Qt.AlignVCenter - width: 24 - height: width + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 } - Column { + ColumnLayout { spacing: Nheko.paddingSmall Layout.fillWidth: true - MatrixText { + Label { id: encryptedText text: { switch (encryptionError) { @@ -58,8 +55,11 @@ Rectangle { return qsTr("Unknown decryption error"); } } + textFormat: Text.PlainText + wrapMode: Label.WordWrap color: palette.text - width: parent.width + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 } Button { @@ -72,4 +72,9 @@ Rectangle { } + background: Rectangle { + color: palette.alternateBase + radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingMedium + visible: !Settings.bubbles // the bubble in a bubble looks odd + } } diff --git a/resources/qml/delegates/EncryptionEnabled.qml b/resources/qml/delegates/EncryptionEnabled.qml
index 0e2b7fc0..40894543 100644 --- a/resources/qml/delegates/EncryptionEnabled.qml +++ b/resources/qml/delegates/EncryptionEnabled.qml
@@ -3,27 +3,24 @@ // SPDX-License-Identifier: GPL-3.0-or-later import ".." -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import im.nheko 1.0 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import im.nheko -Rectangle { +Control { id: r - required property string username + required property string userName - radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium - width: parent.width ? Math.min(parent.width, 700) : 0 - height: contents.implicitHeight + Nheko.paddingMedium * 2 - color: palette.alternateBase - border.color: Nheko.theme.green - border.width: 2 + padding: Nheko.paddingMedium + //implicitHeight: contents.implicitHeight + padd * 2 + Layout.maximumWidth: contents.Layout.maximumWidth + padding * 2 + Layout.fillWidth: true - RowLayout { + contentItem: RowLayout { id: contents - anchors.fill: parent - anchors.margins: Nheko.paddingMedium spacing: Nheko.paddingMedium Image { @@ -33,26 +30,36 @@ Rectangle { Layout.preferredHeight: 24 } - Column { + ColumnLayout { spacing: Nheko.paddingSmall Layout.fillWidth: true MatrixText { - text: qsTr("%1 enabled end-to-end encryption").arg(r.username) + text: qsTr("%1 enabled end-to-end encryption").arg(r.userName) font.bold: true font.pointSize: 14 color: palette.text - width: parent.width + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 } - MatrixText { + Label { text: qsTr("Encryption keeps your messages safe by only allowing the people you sent the message to to read it. For extra security, if you want to make sure you are talking to the right people, you can verify them in real life.") - color: palette.text - width: parent.width + textFormat: Text.PlainText + wrapMode: Label.WordWrap + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 } } } + background: Rectangle { + radius: fontMetrics.lineSpacing / 2 + Nheko.paddingMedium + height: contents.implicitHeight + Nheko.paddingMedium * 2 + color: palette.alternateBase + border.color: Nheko.theme.green + border.width: 2 + } } diff --git a/resources/qml/delegates/FileMessage.qml b/resources/qml/delegates/FileMessage.qml
index 82b82c1b..9f350123 100644 --- a/resources/qml/delegates/FileMessage.qml +++ b/resources/qml/delegates/FileMessage.qml
@@ -2,26 +2,30 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -import QtQuick 2.12 -import QtQuick.Layouts 1.2 -import im.nheko 1.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import im.nheko + +Control { + id: evRoot -Item { required property string eventId required property string filename required property string filesize - height: rowa.height + (Settings.bubbles? 16: 24) - implicitWidth: rowa.implicitWidth + metadataWidth - property int metadataWidth - property bool fitsMetadata: true + padding: Settings.bubbles? 8 : 12 + //Layout.preferredHeight: rowa.implicitHeight + padding + //Layout.maximumWidth: rowa.Layout.maximumWidth + metadataWidth + padding + property int metadataWidth: 0 + property bool fitsMetadata: false + + Layout.maximumWidth: rowa.Layout.maximumWidth + padding * 2 - RowLayout { + contentItem: RowLayout { id: rowa - anchors.centerIn: parent - width: parent.width - (Settings.bubbles? 16 : 24) - spacing: 15 + spacing: 16 Rectangle { id: button @@ -63,6 +67,7 @@ Item { id: filename_ Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 text: filename textFormat: Text.PlainText elide: Text.ElideRight @@ -73,6 +78,7 @@ Item { id: filesize_ Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + 1 text: filesize textFormat: Text.PlainText elide: Text.ElideRight @@ -83,11 +89,9 @@ Item { } - Rectangle { + background: Rectangle { color: palette.alternateBase - z: -1 - radius: 10 - anchors.fill: parent + radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall visible: !Settings.bubbles // the bubble in a bubble looks odd } diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml
index 20d727c3..9c93c25b 100644 --- a/resources/qml/delegates/ImageMessage.qml +++ b/resources/qml/delegates/ImageMessage.qml
@@ -2,29 +2,31 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -import QtQuick 2.15 -import QtQuick.Window 2.15 -import QtQuick.Controls 2.3 -import im.nheko 1.0 +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import im.nheko AbstractButton { required property int type required property int originalWidth + required property int originalHeight required property double proportionalHeight required property string url required property string blurhash required property string body required property string filename - required property bool isReply required property string eventId - property double divisor: isReply ? 5 : 3 + required property int containerHeight + property double divisor: EventDelegateChooser.isReply ? 10 : 4 - property int tempWidth: originalWidth < 1? 400: originalWidth + EventDelegateChooser.keepAspectRatio: true + EventDelegateChooser.maxWidth: originalWidth + EventDelegateChooser.maxHeight: containerHeight / divisor + EventDelegateChooser.aspectRatio: proportionalHeight - implicitWidth: Math.round(tempWidth*Math.min((timelineView.height/divisor)/(tempWidth*proportionalHeight), 1)) - width: Math.min(parent?.width ?? 2000,implicitWidth) - height: width*proportionalHeight hoverEnabled: true + enabled: !EventDelegateChooser.isReply state: (img.status != Image.Ready || timeline.privacyScreen.active) ? "BlurhashVisible" : "ImageVisible" states: [ @@ -116,6 +118,7 @@ AbstractButton { source: url != "" ? (url.replace("mxc://", "image://MxcImage/") + "?scale") : "" asynchronous: true fillMode: Image.PreserveAspectFit + horizontalAlignment: Image.AlignLeft smooth: true mipmap: true @@ -127,21 +130,23 @@ AbstractButton { id: mxcimage visible: loaded - anchors.fill: parent roomm: room play: !Settings.animateImagesOnHover || parent.hovered eventId: parent.eventId + + anchors.fill: parent } Image { id: blurhash_ - anchors.fill: parent source: blurhash ? ("image://blurhash/" + blurhash) : ("image://colorimage/:/icons/icons/ui/image-failed.svg?" + palette.buttonText) asynchronous: true fillMode: Image.PreserveAspectFit sourceSize.width: parent.width * Screen.devicePixelRatio sourceSize.height: parent.height * Screen.devicePixelRatio + + anchors.fill: parent } onClicked: Settings.openImageExternal ? room.openMedia(eventId) : TimelineManager.openImageOverlay(room, url, eventId, originalWidth, proportionalHeight); @@ -150,6 +155,7 @@ AbstractButton { id: overlay anchors.fill: parent + visible: parent.hovered Rectangle { diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml deleted file mode 100644
index 68f65062..00000000 --- a/resources/qml/delegates/MessageDelegate.qml +++ /dev/null
@@ -1,779 +0,0 @@ -// SPDX-FileCopyrightText: Nheko Contributors -// -// SPDX-License-Identifier: GPL-3.0-or-later - -import QtQuick 2.6 -import QtQuick.Controls 2.1 -import QtQuick.Layouts 1.2 -import im.nheko 1.0 - -Item { - id: d - - required property bool isReply - property bool keepFullText: !isReply - property alias child: chooser.child - //implicitWidth: chooser.child?.implicitWidth ?? 0 - required property double proportionalHeight - required property int type - required property string typeString - required property int originalWidth - required property int duration - required property string blurhash - required property string body - required property string formattedBody - required property string eventId - required property string filename - required property string filesize - required property string url - required property string thumbnailUrl - required property bool isOnlyEmoji - required property bool isStateEvent - required property string userId - required property string userName - required property string roomTopic - required property string roomName - required property string callType - required property int encryptionError - required property int relatedEventCacheBuster - property bool fitsMetadata: (chooser.child && chooser.child.fitsMetadata) ? chooser.child.fitsMetadata : false - property int metadataWidth - - implicitWidth: chooser.child?.implicitWidth - - height: chooser.child ? chooser.child.height : Nheko.paddingLarge - - DelegateChooser { - id: chooser - - //role: "type" //< not supported in our custom implementation, have to use roleValue - roleValue: type - //anchors.fill: parent - - width: parent?.width ?? 0 // this should get rid of "cannot read property 'width' of null" - - DelegateChoice { - roleValue: MtxEvent.UnknownEvent - - Placeholder { - typeString: d.typeString - text: "Unretrieved event" - } - - } - - DelegateChoice { - roleValue: MtxEvent.Tombstone - - - ColumnLayout { - width: parent.width - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - Layout.fillWidth: true - formatted: qsTr("This room was replaced for the following reason: %1").arg(d.body) - } - - Button { - Layout.alignment: Qt.AlignHCenter - text: qsTr("Go to replacement room") - onClicked: room.joinReplacementRoom(eventId) - } - - } - } - - DelegateChoice { - roleValue: MtxEvent.TextMessage - - TextMessage { - formatted: d.formattedBody - body: d.body - isOnlyEmoji: d.isOnlyEmoji - isReply: d.isReply - keepFullText: d.keepFullText - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.UnknownMessage - - TextMessage { - formatted: d.formattedBody - body: d.body - isOnlyEmoji: d.isOnlyEmoji - isReply: d.isReply - keepFullText: d.keepFullText - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.ElementEffectMessage - - TextMessage { - formatted: d.formattedBody - body: d.body - isOnlyEmoji: d.isOnlyEmoji - isReply: d.isReply - keepFullText: d.keepFullText - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.NoticeMessage - - NoticeMessage { - formatted: d.formattedBody - body: d.body - isOnlyEmoji: d.isOnlyEmoji - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.EmoteMessage - - NoticeMessage { - formatted: TimelineManager.escapeEmoji(d.userName) + " " + d.formattedBody - color: TimelineManager.userColor(d.userId, palette.base) - body: d.body - isOnlyEmoji: d.isOnlyEmoji - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.ImageMessage - - ImageMessage { - type: d.type - originalWidth: d.originalWidth - proportionalHeight: d.proportionalHeight - url: d.url - blurhash: d.blurhash - body: d.body - filename: d.filename - isReply: d.isReply - eventId: d.eventId - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.Sticker - - ImageMessage { - type: d.type - originalWidth: d.originalWidth - proportionalHeight: d.proportionalHeight - url: d.url - blurhash: d.blurhash - body: d.body - filename: d.filename - isReply: d.isReply - eventId: d.eventId - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.FileMessage - - FileMessage { - eventId: d.eventId - filename: d.filename - filesize: d.filesize - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.VideoMessage - - PlayableMediaMessage { - proportionalHeight: d.proportionalHeight - type: d.type - originalWidth: d.originalWidth - thumbnailUrl: d.thumbnailUrl - eventId: d.eventId - url: d.url - body: d.body - filesize: d.filesize - duration: d.duration - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.AudioMessage - - PlayableMediaMessage { - proportionalHeight: d.proportionalHeight - type: d.type - originalWidth: d.originalWidth - thumbnailUrl: d.thumbnailUrl - eventId: d.eventId - url: d.url - body: d.body - filesize: d.filesize - duration: d.duration - metadataWidth: d.metadataWidth - } - - } - - DelegateChoice { - roleValue: MtxEvent.Redacted - - Redacted { - metadataWidth: d.metadataWidth - } - } - - DelegateChoice { - roleValue: MtxEvent.Redaction - - Pill { - text: qsTr("%1 removed a message").arg(d.userName) - isStateEvent: d.isStateEvent - } - - } - - DelegateChoice { - roleValue: MtxEvent.Encryption - - EncryptionEnabled { - username: d.userName - } - - } - - DelegateChoice { - roleValue: MtxEvent.Encrypted - - Encrypted { - encryptionError: d.encryptionError - eventId: d.eventId - } - - } - - DelegateChoice { - roleValue: MtxEvent.ServerAcl - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 changed which servers are allowed in this room.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.Name - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.roomName ? qsTr("%2 changed the room name to: %1").arg(d.roomName).arg(d.userName) : qsTr("%1 removed the room name").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.Topic - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.roomTopic ? qsTr("%2 changed the topic to: %1").arg(d.roomTopic).arg(d.userName): qsTr("%1 removed the topic").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.Avatar - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 changed the room avatar").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.PinnedEvents - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 changed the pinned messages.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.ImagePackInRoom - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatImagePackEvent(d.eventId) - } - - } - - - DelegateChoice { - roleValue: MtxEvent.CanonicalAlias - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 changed the addresses for this room.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.SpaceParent - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 changed the parent communities for this room.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.RoomCreate - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 created and configured room: %2").arg(d.userName).arg(room.roomId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallInvite - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: { - switch (d.callType) { - case "voice": - return qsTr("%1 placed a voice call.").arg(d.userName); - case "video": - return qsTr("%1 placed a video call.").arg(d.userName); - default: - return qsTr("%1 placed a call.").arg(d.userName); - } - } - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallAnswer - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 answered the call.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallReject - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 rejected the call.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallSelectAnswer - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 select answer").arg(d.userName) - // formatted: qsTr("Call answered elsewhere") - } - } - - DelegateChoice { - roleValue: MtxEvent.CallHangUp - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 ended the call.").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallCandidates - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 is negotiating the call...").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.CallNegotiate - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: qsTr("%1 is negotiating the call...").arg(d.userName) - } - - } - - DelegateChoice { - roleValue: MtxEvent.PowerLevels - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatPowerLevelEvent(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.PolicyRuleUser - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.PolicyRuleRoom - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.PolicyRuleServer - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatPolicyRule(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.RoomJoinRules - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatJoinRuleEvent(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.RoomHistoryVisibility - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatHistoryVisibilityEvent(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.RoomGuestAccess - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: d.relatedEventCacheBuster, room.formatGuestAccessEvent(d.eventId) - } - - } - - DelegateChoice { - roleValue: MtxEvent.Member - - ColumnLayout { - width: parent?.width ?? 100 - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - Layout.fillWidth: true - formatted: d.relatedEventCacheBuster, room.formatMemberEvent(d.eventId) - } - - Button { - visible: d.relatedEventCacheBuster, room.showAcceptKnockButton(d.eventId) - Layout.alignment: Qt.AlignHCenter - text: qsTr("Allow them in") - onClicked: room.acceptKnock(eventId) - } - - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationRequest - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationRequest" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationStart - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationStart" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationReady - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationReady" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationCancel - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationCancel" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationKey - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationKey" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationMac - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationMac" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationDone - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationDone" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationDone - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationDone" - } - - } - - DelegateChoice { - roleValue: MtxEvent.KeyVerificationAccept - - NoticeMessage { - body: formatted - isOnlyEmoji: false - isReply: d.isReply - keepFullText: d.keepFullText - isStateEvent: d.isStateEvent - formatted: "KeyVerificationAccept" - } - - } - - DelegateChoice { - Placeholder { - typeString: d.typeString - } - - } - - } - -} diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml
index fb7bf0cc..99928369 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml
@@ -22,7 +22,7 @@ Item { required property string url required property string body required property string filesize - property double divisor: isReply ? 4 : 2 + property double divisor: EventDelegateChooser.isReply ? 10 : 4 property int tempWidth: originalWidth < 1? 400: originalWidth implicitWidth: type == MtxEvent.VideoMessage ? Math.round(tempWidth*Math.min((timelineView.height/divisor)/(tempWidth*proportionalHeight), 1)) : 500 width: Math.min(parent?.width ?? implicitWidth, implicitWidth) diff --git a/resources/qml/delegates/Redacted.qml b/resources/qml/delegates/Redacted.qml
index 4a9700dc..3c496f08 100644 --- a/resources/qml/delegates/Redacted.qml +++ b/resources/qml/delegates/Redacted.qml
@@ -2,25 +2,22 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import im.nheko 1.0 - -Rectangle{ - - height: redactedLayout.implicitHeight + Nheko.paddingSmall - implicitWidth: redactedLayout.implicitWidth + 2 * Nheko.paddingMedium - width: Math.min(parent.width,implicitWidth+1) - radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall - color: palette.alternateBase - property int metadataWidth - property bool fitsMetadata: parent.width - redactedLayout.width > metadataWidth + 4 - - RowLayout { +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import im.nheko + +Control { + id: msgRoot + + property int metadataWidth: 0 + property bool fitsMetadata: false //parent.width - redactedLayout.width > metadataWidth + 4 + + required property string eventId + required property Room room + + contentItem: RowLayout { id: redactedLayout - anchors.centerIn: parent - width: parent.width - 2 * Nheko.paddingMedium spacing: Nheko.paddingSmall Image { @@ -34,12 +31,11 @@ Rectangle{ id: redactedLabel Layout.margins: 0 Layout.alignment: Qt.AlignVCenter | Qt.AlignRight - Layout.preferredWidth: implicitWidth + Layout.maximumWidth: implicitWidth + 1 Layout.fillWidth: true - property var redactedPair: room.formatRedactedEvent(eventId) + property var redactedPair: room.formatRedactedEvent(msgRoot.eventId) text: redactedPair["first"] wrapMode: Label.WordWrap - color: palette.text ToolTip.text: redactedPair["second"] ToolTip.visible: hh.hovered @@ -48,4 +44,13 @@ Rectangle{ } } } + + padding: Nheko.paddingSmall + + Layout.maximumWidth: redactedLayout.Layout.maximumWidth + padding * 2 + + background: Rectangle { + color: palette.alternateBase + radius: fontMetrics.lineSpacing / 2 + 2 * Nheko.paddingSmall + } } diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml
index 4d4983ac..ece838b7 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml
@@ -14,128 +14,88 @@ AbstractButton { id: r property color userColor: "red" - property double proportionalHeight - property int type - property string typeString - property int originalWidth - property string blurhash - property string body - property string formattedBody - property string eventId - property string filename - property string filesize - property string url - property bool isOnlyEmoji - property bool isStateEvent - property string userId - property string userName - property string thumbnailUrl - property string roomTopic - property string roomName - property string callType - property int duration - property int encryptionError - property int relatedEventCacheBuster - property int maxWidth property bool keepFullText: false - height: replyContainer.height - implicitHeight: replyContainer.height - implicitWidth: visible? colorLine.width+Math.max(replyContainer.implicitWidth,userName_.fullTextWidth) : 0 // visible? seems to be causing issues + required property string eventId + + property var room_: room + + property string userId: eventId ? room.dataById(eventId, Room.UserId, "") : "" + property string userName: eventId ? room.dataById(eventId, Room.UserName, "") : "" + implicitHeight: replyContainer.implicitHeight + implicitWidth: replyContainer.implicitWidth + required property int maxWidth NhekoCursorShape { anchors.fill: parent cursorShape: Qt.PointingHandCursor } - Rectangle { - id: colorLine - - anchors.top: replyContainer.top - anchors.bottom: replyContainer.bottom - width: 4 - color: TimelineManager.userColor(userId, palette.base) - } - onClicked: { - let link = reply.child.linkAt != undefined && reply.child.linkAt(pressX-colorLine.width, pressY - userName_.implicitHeight); + let link = reply.child.linkAt != undefined && reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight); if (link) { Nheko.openLink(link) } else { room.showEvent(r.eventId) } } - onPressAndHold: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(pressX-colorLine.width, pressY - userName_.implicitHeight), r.eventId) + onPressAndHold: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(pressX-colorline.width, pressY - userName_.implicitHeight), r.eventId) - ColumnLayout { - id: replyContainer + contentItem: TimelineEvent { + id: timelineEvent - anchors.left: colorLine.right - width: parent.width - 4 - spacing: 0 + isStateEvent: false + room: room_ + eventId: r.eventId + replyTo: "" + mainInset: 4 + Nheko.paddingMedium + maxWidth: r.maxWidth - TapHandler { - acceptedButtons: Qt.RightButton - onSingleTapped: replyContextMenu.show(reply.child.copyText, reply.child.linkAt(eventPoint.position.x, eventPoint.position.y - userName_.implicitHeight), r.eventId) - gesturePolicy: TapHandler.ReleaseWithinBounds - acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad - } + //height: replyContainer.implicitHeight + data: Row { + id: replyContainer + + spacing: Nheko.paddingSmall - AbstractButton { - Layout.leftMargin: 4 - Layout.fillWidth: true - contentItem: ElidedLabel { - id: userName_ - fullText: userName - color: r.userColor - textFormat: Text.RichText - width: parent.width - elideWidth: width + Rectangle { + id: colorline + + width: 4 + height: content.height + + color: TimelineManager.userColor(r.userId, palette.base) } - onClicked: room.openUserProfile(userId) - } - MessageDelegate { - Layout.leftMargin: 4 - Layout.preferredHeight: height - id: reply - blurhash: r.blurhash - body: r.body - formattedBody: r.formattedBody - eventId: r.eventId - filename: r.filename - filesize: r.filesize - proportionalHeight: r.proportionalHeight - type: r.type - typeString: r.typeString ?? "" - url: r.url - thumbnailUrl: r.thumbnailUrl - duration: r.duration - originalWidth: r.originalWidth - isOnlyEmoji: r.isOnlyEmoji - isStateEvent: r.isStateEvent - userId: r.userId - userName: r.userName - roomTopic: r.roomTopic - roomName: r.roomName - callType: r.callType - relatedEventCacheBuster: r.relatedEventCacheBuster - encryptionError: r.encryptionError - // This is disabled so that left clicking the reply goes to its location - enabled: false - Layout.fillWidth: true - isReply: true - keepFullText: r.keepFullText - } + Column { + id: content + spacing: 0 + + AbstractButton { + id: usernameBtn + + contentItem: Label { + id: userName_ + text: r.userName + color: r.userColor + textFormat: Text.RichText + width: timelineEvent.main?.width + } + onClicked: room.openUserProfile(r.userId) + } + + data: [ + usernameBtn, timelineEvent.main, + ] + } + } } - Rectangle { + background: Rectangle { id: backgroundItem z: -1 - anchors.fill: replyContainer - property color userColor: TimelineManager.userColor(userId, palette.base) + property color userColor: TimelineManager.userColor(r.userId, palette.base) property color bgColor: palette.base color: Qt.tint(bgColor, Qt.hsla(userColor.hslHue, 0.5, userColor.hslLightness, 0.1)) } diff --git a/resources/qml/delegates/TextMessage.qml b/resources/qml/delegates/TextMessage.qml
index aabb7136..d17e61d2 100644 --- a/resources/qml/delegates/TextMessage.qml +++ b/resources/qml/delegates/TextMessage.qml
@@ -9,11 +9,12 @@ import im.nheko MatrixText { required property string body required property bool isOnlyEmoji - required property bool isReply + property bool isReply: EventDelegateChooser.isReply required property bool keepFullText required property string formatted + property string copyText: selectedText ? getText(selectionStart, selectionEnd) : body - property int metadataWidth + property int metadataWidth: 100 property bool fitsMetadata: false //positionAt(width,height-4) == positionAt(width-metadataWidth-10, height-4) // table border-collapse doesn't seem to work @@ -39,11 +40,8 @@ MatrixText { }" : "") + // TODO(Nico): Figure out how to support mobile "</style> " + formatted.replace(/<del>/g, "<s>").replace(/<\/del>/g, "</s>").replace(/<strike>/g, "<s>").replace(/<\/strike>/g, "</s>") - width: parent?.width ?? 0 - height: !keepFullText ? Math.round(Math.min(timelineView.height / 8, implicitHeight)) : implicitHeight - clip: !keepFullText - selectByMouse: !isReply -// enabled: !Settings.mobileMode + + enabled: !isReply font.pointSize: (Settings.enlargeEmojiOnlyMessages && isOnlyEmoji > 0 && isOnlyEmoji < 4) ? Settings.fontSize * 3 : Settings.fontSize NhekoCursorShape { diff --git a/resources/qml/dialogs/AliasEditor.qml b/resources/qml/dialogs/AliasEditor.qml
index c49ad321..df2938ef 100644 --- a/resources/qml/dialogs/AliasEditor.qml +++ b/resources/qml/dialogs/AliasEditor.qml
@@ -4,10 +4,10 @@ import ".." import "../components" -import QtQuick 2.12 -import QtQuick.Controls 2.5 -import QtQuick.Layouts 1.3 -import im.nheko 1.0 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import im.nheko ApplicationWindow { diff --git a/resources/qml/ui/TimelineEffects.qml b/resources/qml/ui/TimelineEffects.qml
index 72237e31..4960ce32 100644 --- a/resources/qml/ui/TimelineEffects.qml +++ b/resources/qml/ui/TimelineEffects.qml
@@ -9,6 +9,7 @@ Item { id: effectRoot readonly property int maxLifespan: Math.max(confettiEmitter.lifeSpan, rainfallEmitter.lifeSpan) required property bool shouldEffectsRun + visible: effectRoot.shouldEffectsRun function pulseConfetti() { @@ -20,11 +21,17 @@ Item { rainfallEmitter.pulse(effectRoot.height * 3.3) } + function removeParticles() + { + particleSystem.reset() + } + ParticleSystem { id: particleSystem - Component.onCompleted: pause(); + Component.onCompleted: stop(); paused: !effectRoot.shouldEffectsRun + running: effectRoot.shouldEffectsRun } Emitter { @@ -89,26 +96,47 @@ Item { enabled: false anchors.horizontalCenter: effectRoot.horizontalCenter y: -60 - emitRate: effectRoot.width / 50 + emitRate: effectRoot.width / 30 lifeSpan: 10000 system: particleSystem velocity: PointDirection { x: 0 - y: 300 + y: 400 xVariation: 0 yVariation: 75 } - ItemParticle { - system: particleSystem - groups: ["rain"] - fade: false - delegate: Rectangle { - width: 2 - height: 30 + 30 * Math.random() - radius: 2 + // causes high CPU load, see: https://bugreports.qt.io/browse/QTBUG-117923 + //ItemParticle { + // system: particleSystem + // groups: ["rain"] + // fade: false + // visible: effectRoot.shouldEffectsRun + // delegate: Rectangle { + // width: 2 + // height: 30 + 30 * Math.random() + // radius: 2 + // color: "#0099ff" + // } + //} + + ImageParticle { + system: particleSystem + groups: ["rain"] + source: "qrc:/confettiparticle.svg" + rotationVelocity: 0 + rotationVelocityVariation: 0 + colorVariation: 0 color: "#0099ff" + entryEffect: ImageParticle.None + xVector: PointDirection { + x: 0.01 + y: 0 + } + yVector: PointDirection { + x: 0 + y: 5 + } } } } -} diff --git a/src/AliasEditModel.cpp b/src/AliasEditModel.cpp
index 218c5b36..c6dc35c6 100644 --- a/src/AliasEditModel.cpp +++ b/src/AliasEditModel.cpp
@@ -42,7 +42,7 @@ AliasEditingModel::AliasEditingModel(const std::string &rid, QObject *parent) } } - for (const auto &alias : qAsConst(aliases)) { + for (const auto &alias : std::as_const(aliases)) { fetchAliasesStatus(alias.alias); } fetchPublishedAliases(); @@ -148,7 +148,7 @@ void AliasEditingModel::addAlias(QString newAlias) { const auto aliasStr = newAlias.toStdString(); - for (const auto &e : qAsConst(aliases)) { + for (const auto &e : std::as_const(aliases)) { if (e.alias == aliasStr) { return; } diff --git a/src/Cache.cpp b/src/Cache.cpp
index 0426f38a..8ad850ac 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp
@@ -37,7 +37,7 @@ //! Should be changed when a breaking change occurs in the cache format. //! This will reset client's data. -static const std::string CURRENT_CACHE_FORMAT_VERSION{"2023.03.12"}; +static const std::string CURRENT_CACHE_FORMAT_VERSION{"2023.10.22"}; //! Keys used for the DB static const std::string_view NEXT_BATCH_KEY("next_batch"); @@ -91,10 +91,30 @@ static constexpr auto INBOUND_MEGOLM_SESSIONS_DB("inbound_megolm_sessions"); static constexpr auto OUTBOUND_MEGOLM_SESSIONS_DB("outbound_megolm_sessions"); //! MegolmSessionIndex -> session data about which devices have access to this static constexpr auto MEGOLM_SESSIONS_DATA_DB("megolm_sessions_data_db"); +//! Curve25519 key to session_id and json encoded olm session, separated by null. Dupsorted. +static constexpr auto OLM_SESSIONS_DB("olm_sessions.v3"); + +//! flag to be set, when the db should be compacted on startup +bool needsCompact = false; using CachedReceipts = std::multimap<uint64_t, std::string, std::greater<uint64_t>>; using Receipts = std::map<std::string, std::map<std::string, uint64_t>>; +static std::string +combineOlmSessionKeyFromCurveAndSessionId(std::string_view curve25519, std::string_view session_id) +{ + std::string combined(curve25519.size() + 1 + session_id.size(), '\0'); + combined.replace(0, curve25519.size(), curve25519); + combined.replace(curve25519.size() + 1, session_id.size(), session_id); + return combined; +} +static std::pair<std::string_view, std::string_view> +splitCurve25519AndOlmSessionId(std::string_view input) +{ + auto separator = input.find('\0'); + return std::pair(input.substr(0, separator), input.substr(separator + 1)); +} + namespace { std::unique_ptr<Cache> instance_ = nullptr; } @@ -132,6 +152,49 @@ ro_txn(lmdb::env &env) return RO_txn{txn}; } +static void +compactDatabase(lmdb::env &from, lmdb::env &to) +{ + auto fromTxn = lmdb::txn::begin(from, nullptr, MDB_RDONLY); + auto toTxn = lmdb::txn::begin(to); + + auto rootDb = lmdb::dbi::open(fromTxn); + auto dbNames = lmdb::cursor::open(fromTxn, rootDb); + + std::string_view dbName; + while (dbNames.get(dbName, MDB_cursor_op::MDB_NEXT_NODUP)) { + nhlog::db()->info("Compacting db: {}", dbName); + + auto flags = MDB_CREATE; + + if (dbName.ends_with("/event_order") || dbName.ends_with("/order2msg") || + dbName.ends_with("/pending")) + flags |= MDB_INTEGERKEY; + if (dbName.ends_with("/related") || dbName.ends_with("/states_key") || + dbName == SPACES_CHILDREN_DB || dbName == SPACES_PARENTS_DB) + flags |= MDB_DUPSORT; + + auto dbNameStr = std::string(dbName); + auto fromDb = lmdb::dbi::open(fromTxn, dbNameStr.c_str(), flags); + auto toDb = lmdb::dbi::open(toTxn, dbNameStr.c_str(), flags); + + if (dbName.ends_with("/states_key")) { + lmdb::dbi_set_dupsort(fromTxn, fromDb, Cache::compare_state_key); + lmdb::dbi_set_dupsort(toTxn, toDb, Cache::compare_state_key); + } + + auto fromCursor = lmdb::cursor::open(fromTxn, fromDb); + auto toCursor = lmdb::cursor::open(toTxn, toDb); + + std::string_view key, val; + while (fromCursor.get(key, val, MDB_cursor_op::MDB_NEXT)) { + toCursor.put(key, val, MDB_APPENDDUP); + } + } + + toTxn.commit(); +} + template<class T> bool containsStateUpdates(const T &e) @@ -266,9 +329,13 @@ Cache::setup() nhlog::db()->info("completed state migration"); } - env_ = lmdb::env::create(); - env_.set_mapsize(DB_SIZE); - env_.set_max_dbs(MAX_DBS); + auto openEnv = [](const QString &name) { + auto e = lmdb::env::create(); + e.set_mapsize(DB_SIZE); + e.set_max_dbs(MAX_DBS); + e.open(name.toStdString().c_str(), MDB_NOMETASYNC | MDB_NOSYNC); + return e; + }; if (isInitial) { nhlog::db()->info("initializing LMDB"); @@ -291,7 +358,41 @@ Cache::setup() // corruption is an lmdb or filesystem bug. See // https://github.com/Nheko-Reborn/nheko/issues/1355 // https://github.com/Nheko-Reborn/nheko/issues/1303 - env_.open(cacheDirectory_.toStdString().c_str(), MDB_NOMETASYNC | MDB_NOSYNC); + env_ = openEnv(cacheDirectory_); + + if (needsCompact) { + auto compactDir = QStringLiteral("%1-compacting").arg(cacheDirectory_); + auto toDeleteDir = QStringLiteral("%1-olddb").arg(cacheDirectory_); + if (QFile::exists(cacheDirectory_)) + QDir(compactDir).removeRecursively(); + if (QFile::exists(toDeleteDir)) + QDir(toDeleteDir).removeRecursively(); + if (!QDir().mkpath(compactDir)) { + nhlog::db()->warn( + "Failed to create directory '{}' for database compaction, skipping compaction!", + compactDir.toStdString()); + } else { + // lmdb::env_copy(env_, compactDir.toStdString().c_str(), MDB_CP_COMPACT); + + // create a temporary db + auto temp = openEnv(compactDir); + + // copy data + compactDatabase(env_, temp); + + // close envs + temp.close(); + env_.close(); + + // swap the databases and delete old one + QDir().rename(cacheDirectory_, toDeleteDir); + QDir().rename(compactDir, cacheDirectory_); + QDir(toDeleteDir).removeRecursively(); + + // reopen env + env_ = openEnv(cacheDirectory_); + } + } } catch (const lmdb::error &e) { if (e.code() != MDB_VERSION_MISMATCH && e.code() != MDB_INVALID) { throw std::runtime_error("LMDB initialization failed" + std::string(e.what())); @@ -302,11 +403,11 @@ Cache::setup() QDir stateDir(cacheDirectory_); auto eList = stateDir.entryList(QDir::NoDotAndDotDot); - for (const auto &file : qAsConst(eList)) { + for (const auto &file : std::as_const(eList)) { if (!stateDir.remove(file)) throw std::runtime_error(("Unable to delete file " + file).toStdString().c_str()); } - env_.open(cacheDirectory_.toStdString().c_str()); + env_ = openEnv(cacheDirectory_); } auto txn = lmdb::txn::begin(env_); @@ -328,6 +429,8 @@ Cache::setup() outboundMegolmSessionDb_ = lmdb::dbi::open(txn, OUTBOUND_MEGOLM_SESSIONS_DB, MDB_CREATE); megolmSessionDataDb_ = lmdb::dbi::open(txn, MEGOLM_SESSIONS_DATA_DB, MDB_CREATE); + olmSessionDb_ = lmdb::dbi::open(txn, OLM_SESSIONS_DB, MDB_CREATE); + // What rooms are encrypted encryptedRooms_ = lmdb::dbi::open(txn, ENCRYPTED_ROOMS_DB, MDB_CREATE); eventExpiryBgJob_ = lmdb::dbi::open(txn, EVENT_EXPIRATION_BG_JOB_DB, MDB_CREATE); @@ -991,8 +1094,6 @@ Cache::saveOlmSessions(std::vector<std::pair<std::string, mtx::crypto::OlmSessio auto txn = lmdb::txn::begin(env_); for (const auto &[curve25519, session] : sessions) { - auto db = getOlmSessionsDb(txn, curve25519); - const auto pickled = pickle<SessionObject>(session.get(), pickle_secret_); const auto session_id = mtx::crypto::session_id(session.get()); @@ -1000,7 +1101,9 @@ Cache::saveOlmSessions(std::vector<std::pair<std::string, mtx::crypto::OlmSessio stored_session.pickled_session = pickled; stored_session.last_message_ts = timestamp; - db.put(txn, session_id, nlohmann::json(stored_session).dump()); + olmSessionDb_.put(txn, + combineOlmSessionKeyFromCurveAndSessionId(curve25519, session_id), + nlohmann::json(stored_session).dump()); } txn.commit(); @@ -1014,7 +1117,6 @@ Cache::saveOlmSession(const std::string &curve25519, using namespace mtx::crypto; auto txn = lmdb::txn::begin(env_); - auto db = getOlmSessionsDb(txn, curve25519); const auto pickled = pickle<SessionObject>(session.get(), pickle_secret_); const auto session_id = mtx::crypto::session_id(session.get()); @@ -1023,7 +1125,9 @@ Cache::saveOlmSession(const std::string &curve25519, stored_session.pickled_session = pickled; stored_session.last_message_ts = timestamp; - db.put(txn, session_id, nlohmann::json(stored_session).dump()); + olmSessionDb_.put(txn, + combineOlmSessionKeyFromCurveAndSessionId(curve25519, session_id), + nlohmann::json(stored_session).dump()); txn.commit(); } @@ -1035,10 +1139,10 @@ Cache::getOlmSession(const std::string &curve25519, const std::string &session_i try { auto txn = ro_txn(env_); - auto db = getOlmSessionsDb(txn, curve25519); std::string_view pickled; - bool found = db.get(txn, session_id, pickled); + bool found = olmSessionDb_.get( + txn, combineOlmSessionKeyFromCurveAndSessionId(curve25519, session_id), pickled); if (found) { auto data = nlohmann::json::parse(pickled).get<StoredOlmSession>(); @@ -1057,14 +1161,20 @@ Cache::getLatestOlmSession(const std::string &curve25519) try { auto txn = ro_txn(env_); - auto db = getOlmSessionsDb(txn, curve25519); - std::string_view session_id, pickled_session; + std::string_view key = curve25519, pickled_session; std::optional<StoredOlmSession> currentNewest; - auto cursor = lmdb::cursor::open(txn, db); - while (cursor.get(session_id, pickled_session, MDB_NEXT)) { + auto cursor = lmdb::cursor::open(txn, olmSessionDb_); + bool first = true; + while (cursor.get(key, pickled_session, first ? MDB_SET_RANGE : MDB_NEXT)) { + first = false; + + auto storedCurve = splitCurve25519AndOlmSessionId(key).first; + if (storedCurve != curve25519) + break; + auto data = nlohmann::json::parse(pickled_session).get<StoredOlmSession>(); if (!currentNewest || currentNewest->last_message_ts < data.last_message_ts) currentNewest = data; @@ -1086,14 +1196,21 @@ Cache::getOlmSessions(const std::string &curve25519) try { auto txn = ro_txn(env_); - auto db = getOlmSessionsDb(txn, curve25519); - std::string_view session_id, unused; + std::string_view key = curve25519, value; std::vector<std::string> res; - auto cursor = lmdb::cursor::open(txn, db); - while (cursor.get(session_id, unused, MDB_NEXT)) + auto cursor = lmdb::cursor::open(txn, olmSessionDb_); + + bool first = true; + while (cursor.get(key, value, first ? MDB_SET_RANGE : MDB_NEXT)) { + first = false; + + auto [storedCurve, session_id] = splitCurve25519AndOlmSessionId(key); + if (storedCurve != curve25519) + break; res.emplace_back(session_id); + } cursor.close(); return res; @@ -1603,6 +1720,46 @@ Cache::runMigrations() nhlog::db()->info("Successfully updated states key database format."); return true; }}, + {"2023.10.22", + [this]() { + // migrate olm sessions to a single db + try { + auto txn = lmdb::txn::begin(env_, nullptr); + auto mainDb = lmdb::dbi::open(txn); + auto dbNames = lmdb::cursor::open(txn, mainDb); + + std::string_view dbName; + while (dbNames.get(dbName, MDB_NEXT)) { + if (!dbName.starts_with("olm_sessions.v2/")) + continue; + + auto curveKey = dbName; + curveKey.remove_prefix(std::string_view("olm_sessions.v2/").size()); + + auto oldDb = lmdb::dbi::open(txn, std::string(dbName).c_str()); + auto olmCursor = lmdb::cursor::open(txn, oldDb); + + std::string_view session_id, json; + while (olmCursor.get(session_id, json, MDB_NEXT)) { + olmSessionDb_.put( + txn, + combineOlmSessionKeyFromCurveAndSessionId(curveKey, session_id), + json); + } + + oldDb.drop(txn, true); + } + + txn.commit(); + } catch (const lmdb::error &e) { + nhlog::db()->critical("Failed to convert olm sessions database in migration! {}", + e.what()); + return false; + } + + nhlog::db()->info("Successfully updated olm sessions database format."); + return true; + }}, }; nhlog::db()->info("Running migrations, this may take a while!"); @@ -5366,6 +5523,12 @@ from_json(const nlohmann::json &obj, StoredOlmSession &msg) namespace cache { void +setNeedsCompactFlag() +{ + needsCompact = true; +} + +void init(const QString &user_id) { instance_ = std::make_unique<Cache>(user_id); diff --git a/src/Cache.h b/src/Cache.h
index 113ee42e..bed4938c 100644 --- a/src/Cache.h +++ b/src/Cache.h
@@ -27,6 +27,9 @@ struct Notifications; namespace cache { void +setNeedsCompactFlag(); + +void init(const QString &user_id); std::string diff --git a/src/Cache_p.h b/src/Cache_p.h
index 8d51c7c4..e59796ed 100644 --- a/src/Cache_p.h +++ b/src/Cache_p.h
@@ -594,11 +594,6 @@ private: const std::set<std::string> &spaces_with_updates, std::set<std::string> rooms_with_updates); - lmdb::dbi getPendingReceiptsDb(lmdb::txn &txn) - { - return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE); - } - lmdb::dbi getEventsDb(lmdb::txn &txn, const std::string &room_id) { return lmdb::dbi::open(txn, std::string(room_id + "/events").c_str(), MDB_CREATE); @@ -679,16 +674,6 @@ private: return lmdb::dbi::open(txn, "verified", MDB_CREATE); } - //! Retrieves or creates the database that stores the open OLM sessions between our device - //! and the given curve25519 key which represents another device. - //! - //! Each entry is a map from the session_id to the pickled representation of the session. - lmdb::dbi getOlmSessionsDb(lmdb::txn &txn, const std::string &curve25519_key) - { - return lmdb::dbi::open( - txn, std::string("olm_sessions.v2/" + curve25519_key).c_str(), MDB_CREATE); - } - QString getDisplayName(const mtx::events::StateEvent<mtx::events::state::Member> &event) { if (!event.content.display_name.empty()) @@ -718,6 +703,7 @@ private: lmdb::dbi inboundMegolmSessionDb_; lmdb::dbi outboundMegolmSessionDb_; lmdb::dbi megolmSessionDataDb_; + lmdb::dbi olmSessionDb_; lmdb::dbi encryptedRooms_; diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 06d88303..90d542dd 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp
@@ -588,6 +588,10 @@ ChatPage::loadStateFromCache() try { olm::client()->load(cache::restoreOlmAccount(), cache::client()->pickleSecret()); + nhlog::db()->info("Removing old cached messages"); + cache::deleteOldData(); + nhlog::db()->info("Message removal done"); + emit initializeEmptyViews(); cache::calculateRoomReadStatus(); @@ -769,14 +773,6 @@ ChatPage::handleSyncResponse(const mtx::responses::Sync &res, const std::string auto updates = cache::getRoomInfo(cache::client()->roomsWithStateUpdates(res)); emit syncUI(std::move(res)); - - // if we process a lot of syncs (1 every 200ms), this means we clean the - // db every 100s - static int syncCounter = 0; - if (syncCounter++ >= 500) { - cache::deleteOldData(); - syncCounter = 0; - } } catch (const lmdb::map_full_error &e) { nhlog::db()->error("lmdb is full: {}", e.what()); cache::deleteOldData(); @@ -1577,7 +1573,7 @@ ChatPage::handleMatrixUri(QString uri) auto items = uri_.query(QUrl::ComponentFormattingOption::FullyEncoded).split('&', Qt::SkipEmptyParts); - for (QString item : qAsConst(items)) { + for (QString item : std::as_const(items)) { nhlog::ui()->info("item: {}", item.toStdString()); if (item.startsWith(QLatin1String("action="))) { diff --git a/src/InviteesModel.cpp b/src/InviteesModel.cpp
index 7b49c234..76f37fad 100644 --- a/src/InviteesModel.cpp +++ b/src/InviteesModel.cpp
@@ -18,7 +18,7 @@ InviteesModel::InviteesModel(TimelineModel *room, QObject *parent) void InviteesModel::addUser(QString mxid, QString displayName, QString avatarUrl) { - for (const auto &invitee : qAsConst(invitees_)) + for (const auto &invitee : std::as_const(invitees_)) if (invitee->mxid_ == mxid) return; @@ -79,7 +79,7 @@ InviteesModel::mxids() { QStringList mxidList; mxidList.reserve(invitees_.size()); - for (auto &invitee : qAsConst(invitees_)) + for (auto &invitee : std::as_const(invitees_)) mxidList.push_back(invitee->mxid_); return mxidList; } diff --git a/src/MxcImageProvider.cpp b/src/MxcImageProvider.cpp
index 47e0344f..8f930c1a 100644 --- a/src/MxcImageProvider.cpp +++ b/src/MxcImageProvider.cpp
@@ -38,7 +38,7 @@ MxcImageProvider::MxcImageProvider() QDir::Filter::Writable | QDir::Filter::NoDotAndDotDot | QDir::Filter::Files); auto files = dir.entryInfoList(); - for (const auto &fileInfo : qAsConst(files)) { + for (const auto &fileInfo : std::as_const(files)) { if (fileInfo.fileTime(QFile::FileTime::FileAccessTime) .daysTo(QDateTime::currentDateTime()) > 30) { if (QFile::remove(fileInfo.absoluteFilePath())) diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp
index f0fd9194..01337f11 100644 --- a/src/PowerlevelsEditModels.cpp +++ b/src/PowerlevelsEditModels.cpp
@@ -83,7 +83,7 @@ std::map<std::string, mtx::events::state::power_level_t, std::less<>> PowerlevelsTypeListModel::toEvents() const { std::map<std::string, mtx::events::state::power_level_t, std::less<>> m; - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key.find('.') != std::string::npos) m[key] = pl; return m; @@ -91,7 +91,7 @@ PowerlevelsTypeListModel::toEvents() const mtx::events::state::power_level_t PowerlevelsTypeListModel::kick() const { - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key == "kick") return pl; return powerLevels_.users_default; @@ -99,7 +99,7 @@ PowerlevelsTypeListModel::kick() const mtx::events::state::power_level_t PowerlevelsTypeListModel::invite() const { - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key == "invite") return pl; return powerLevels_.users_default; @@ -107,7 +107,7 @@ PowerlevelsTypeListModel::invite() const mtx::events::state::power_level_t PowerlevelsTypeListModel::ban() const { - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key == "ban") return pl; return powerLevels_.users_default; @@ -115,7 +115,7 @@ PowerlevelsTypeListModel::ban() const mtx::events::state::power_level_t PowerlevelsTypeListModel::eventsDefault() const { - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key == "zdefault_events") return pl; return powerLevels_.users_default; @@ -123,7 +123,7 @@ PowerlevelsTypeListModel::eventsDefault() const mtx::events::state::power_level_t PowerlevelsTypeListModel::stateDefault() const { - for (const auto &[key, pl] : qAsConst(types)) + for (const auto &[key, pl] : std::as_const(types)) if (key == "zdefault_states") return pl; return powerLevels_.users_default; @@ -399,7 +399,7 @@ std::map<std::string, mtx::events::state::power_level_t, std::less<>> PowerlevelsUserListModel::toUsers() const { std::map<std::string, mtx::events::state::power_level_t, std::less<>> m; - for (const auto &[key, pl] : qAsConst(users)) + for (const auto &[key, pl] : std::as_const(users)) if (key.size() > 0 && key.at(0) == '@') m[key] = pl; return m; @@ -407,7 +407,7 @@ PowerlevelsUserListModel::toUsers() const mtx::events::state::power_level_t PowerlevelsUserListModel::usersDefault() const { - for (const auto &[key, pl] : qAsConst(users)) + for (const auto &[key, pl] : std::as_const(users)) if (key == "default") return pl; return powerLevels_.users_default; @@ -635,7 +635,7 @@ PowerlevelEditingModels::updateSpacesModel() void PowerlevelEditingModels::addRole(int pl) { - for (const auto &e : qAsConst(types_.types)) + for (const auto &e : std::as_const(types_.types)) if (pl == int(e.pl)) return; @@ -752,7 +752,7 @@ PowerlevelsSpacesListModel::commit() { std::vector<std::string> spacesToApplyTo; - for (const auto &s : qAsConst(spaces)) + for (const auto &s : std::as_const(spaces)) if (s.apply) spacesToApplyTo.push_back(s.roomid); diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 75a6b443..4a25880c 100644 --- a/src/UserSettingsPage.cpp +++ b/src/UserSettingsPage.cpp
@@ -152,7 +152,7 @@ UserSettings::load(std::optional<QString> profile) collapsedSpaces_.clear(); auto tempSpaces = settings.value(prefix + "user/collapsed_spaces", QList<QVariant>{}).toList(); - for (const auto &e : qAsConst(tempSpaces)) + for (const auto &e : std::as_const(tempSpaces)) collapsedSpaces_.push_back(e.toStringList()); shareKeysWithTrustedUsers_ = @@ -962,7 +962,7 @@ UserSettings::save() QVariantList v; v.reserve(collapsedSpaces_.size()); - for (const auto &e : qAsConst(collapsedSpaces_)) + for (const auto &e : std::as_const(collapsedSpaces_)) v.push_back(e); settings.setValue(prefix + "user/collapsed_spaces", v); diff --git a/src/Utils.cpp b/src/Utils.cpp
index 0ea42a27..ec73c901 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp
@@ -428,7 +428,7 @@ utils::escapeBlacklistedHtml(const QString &rawStr) "blockquote", "/blockquote", "p", "/p", "a", "/a", "ul", "/ul", "ol", "/ol", "sup", "/sup", "sub", "/sub", "li", "/li", "b", "/b", "i", "/i", "u", "/u", "strong", "/strong", - "em", "/em", "strike", "/strike", "code", "/code", "hr", "/hr", + "em", "/em", "strike", "/strike", "code", "/code", "hr", "hr/", "br", "br/", "div", "/div", "table", "/table", "thead", "/thead", "tbody", "/tbody", "tr", "/tr", "th", "/th", "td", "/td", "caption", "/caption", "pre", "/pre", "span", "/span", "img", "/img", diff --git a/src/encryption/Olm.cpp b/src/encryption/Olm.cpp
index 8993f715..7fa176b0 100644 --- a/src/encryption/Olm.cpp +++ b/src/encryption/Olm.cpp
@@ -719,7 +719,7 @@ try_olm_decryption(const std::string &sender_key, const mtx::events::msg::OlmCip nhlog::crypto()->debug("Updated olm session: {}", mtx::crypto::session_id(session->get())); cache::saveOlmSession( - id, std::move(session.value()), QDateTime::currentMSecsSinceEpoch()); + sender_key, std::move(session.value()), QDateTime::currentMSecsSinceEpoch()); } catch (const mtx::crypto::olm_exception &e) { nhlog::crypto()->debug("failed to decrypt olm message ({}, {}) with {}: {}", msg.type, diff --git a/src/main.cpp b/src/main.cpp
index 3984f4ba..36326b13 100644 --- a/src/main.cpp +++ b/src/main.cpp
@@ -20,8 +20,8 @@ #include <QStandardPaths> #include <QTranslator> +#include "Cache.h" #include "ChatPage.h" -#include "Config.h" #include "Logging.h" #include "MainWindow.h" #include "MatrixClient.h" @@ -226,6 +226,10 @@ main(int argc, char *argv[]) "The default is 'file,stderr'. types:{file,stderr,none}"), QObject::tr("type")); parser.addOption(logType); + QCommandLineOption compactDb( + QStringList() << QStringLiteral("C") << QStringLiteral("compact"), + QObject::tr("Recompacts the database which might improve performance.")); + parser.addOption(compactDb); // This option is not actually parsed via Qt due to the need to parse it before the app // name is set. It only exists to keep Qt from complaining about the --profile/-p @@ -240,6 +244,9 @@ main(int argc, char *argv[]) parser.process(app); + if (parser.isSet(compactDb)) + cache::setNeedsCompactFlag(); + // This check needs to happen _after_ process(), so that we actually print help for --help when // Nheko is already running. if (app.isSecondary()) { diff --git a/src/notifications/Manager.cpp b/src/notifications/Manager.cpp
index a54256ae..ed5c0670 100644 --- a/src/notifications/Manager.cpp +++ b/src/notifications/Manager.cpp
@@ -45,7 +45,7 @@ NotificationsManager::removeNotifications(const QString &roomId_, markerPos = std::max(markerPos, cache::getEventIndex(room_id, e.toStdString()).value_or(0)); } - for (const auto &[roomId, eventId] : qAsConst(this->notificationIds)) { + for (const auto &[roomId, eventId] : std::as_const(this->notificationIds)) { if (roomId != roomId_) continue; auto idx = cache::getEventIndex(room_id, eventId.toStdString()); diff --git a/src/notifications/ManagerLinux.cpp b/src/notifications/ManagerLinux.cpp
index fc92c9ae..e838bb85 100644 --- a/src/notifications/ManagerLinux.cpp +++ b/src/notifications/ManagerLinux.cpp
@@ -36,14 +36,14 @@ NotificationsManager::NotificationsManager(QObject *parent) this) , hasMarkup_{std::invoke([this]() -> bool { auto caps = dbus.call("GetCapabilities").arguments(); - for (const auto &x : qAsConst(caps)) + for (const auto &x : std::as_const(caps)) if (x.toStringList().contains("body-markup")) return true; return false; })} , hasImages_{std::invoke([this]() -> bool { auto caps = dbus.call("GetCapabilities").arguments(); - for (const auto &x : qAsConst(caps)) + for (const auto &x : std::as_const(caps)) if (x.toStringList().contains("body-images")) return true; return false; diff --git a/src/timeline/CommunitiesModel.cpp b/src/timeline/CommunitiesModel.cpp
index 3c09d747..e1018f38 100644 --- a/src/timeline/CommunitiesModel.cpp +++ b/src/timeline/CommunitiesModel.cpp
@@ -581,7 +581,7 @@ CommunitiesModel::setCurrentTagId(const QString &tagId) if (tagId.startsWith(QLatin1String("tag:"))) { auto tag = tagId.mid(4); - for (const auto &t : qAsConst(tags_)) { + for (const auto &t : std::as_const(tags_)) { if (t == tag) { this->currentTagId_ = tagId; UserSettings::instance()->setCurrentTagId(tagId); diff --git a/src/timeline/DelegateChooser.cpp b/src/timeline/DelegateChooser.cpp
index 91b2194b..b95116ec 100644 --- a/src/timeline/DelegateChooser.cpp +++ b/src/timeline/DelegateChooser.cpp
@@ -96,7 +96,7 @@ DelegateChooser::clearChoices(QQmlListProperty<DelegateChoice> *p) void DelegateChooser::recalcChild() { - for (const auto choice : qAsConst(choices_)) { + for (const auto choice : std::as_const(choices_)) { const auto &choiceValue = choice->roleValueRef(); if (choiceValue == roleValue_ || (!choiceValue.isValid() && !roleValue_.isValid())) { if (child_) { @@ -134,7 +134,7 @@ DelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status) } else if (status == QQmlIncubator::Error) { auto errors_ = errors(); - for (const auto &e : qAsConst(errors_)) + for (const auto &e : std::as_const(errors_)) nhlog::ui()->error("Error instantiating delegate: {}", e.toString().toStdString()); } } diff --git a/src/timeline/EventDelegateChooser.cpp b/src/timeline/EventDelegateChooser.cpp new file mode 100644
index 00000000..99a4cf3a --- /dev/null +++ b/src/timeline/EventDelegateChooser.cpp
@@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "EventDelegateChooser.h" +#include "TimelineModel.h" + +#include "Logging.h" + +#include <QQmlEngine> +#include <QtGlobal> + +#include <ranges> + +// privat qt headers to access required properties +#include <QtQml/private/qqmlincubator_p.h> +#include <QtQml/private/qqmlobjectcreator_p.h> + +QQmlComponent * +EventDelegateChoice::delegate() const +{ + return delegate_; +} + +void +EventDelegateChoice::setDelegate(QQmlComponent *delegate) +{ + if (delegate != delegate_) { + delegate_ = delegate; + emit delegateChanged(); + emit changed(); + } +} + +QList<int> +EventDelegateChoice::roleValues() const +{ + return roleValues_; +} + +void +EventDelegateChoice::setRoleValues(const QList<int> &value) +{ + if (value != roleValues_) { + roleValues_ = value; + emit roleValuesChanged(); + emit changed(); + } +} + +QQmlListProperty<EventDelegateChoice> +EventDelegateChooser::choices() +{ + return QQmlListProperty<EventDelegateChoice>(this, + this, + &EventDelegateChooser::appendChoice, + &EventDelegateChooser::choiceCount, + &EventDelegateChooser::choice, + &EventDelegateChooser::clearChoices); +} + +void +EventDelegateChooser::appendChoice(QQmlListProperty<EventDelegateChoice> *p, EventDelegateChoice *c) +{ + EventDelegateChooser *dc = static_cast<EventDelegateChooser *>(p->object); + dc->choices_.append(c); +} + +qsizetype +EventDelegateChooser::choiceCount(QQmlListProperty<EventDelegateChoice> *p) +{ + return static_cast<EventDelegateChooser *>(p->object)->choices_.count(); +} +EventDelegateChoice * +EventDelegateChooser::choice(QQmlListProperty<EventDelegateChoice> *p, qsizetype index) +{ + return static_cast<EventDelegateChooser *>(p->object)->choices_.at(index); +} +void +EventDelegateChooser::clearChoices(QQmlListProperty<EventDelegateChoice> *p) +{ + static_cast<EventDelegateChooser *>(p->object)->choices_.clear(); +} + +void +EventDelegateChooser::componentComplete() +{ + QQuickItem::componentComplete(); + eventIncubator.reset(eventId_); + replyIncubator.reset(replyId); + // eventIncubator.forceCompletion(); +} + +void +EventDelegateChooser::DelegateIncubator::setInitialState(QObject *obj) +{ + auto item = qobject_cast<QQuickItem *>(obj); + if (!item) + return; + + item->setParentItem(&chooser); + item->setParent(&chooser); + + auto roleNames = chooser.room_->roleNames(); + QHash<QByteArray, int> nameToRole; + for (const auto &[k, v] : roleNames.asKeyValueRange()) { + nameToRole.insert(v, k); + } + + QHash<int, int> roleToPropIdx; + std::vector<QModelRoleData> roles; + // Workaround for https://bugreports.qt.io/browse/QTBUG-98846 + QHash<QString, RequiredPropertyKey> requiredProperties; + for (const auto &[propKey, prop] : + QQmlIncubatorPrivate::get(this)->requiredProperties()->asKeyValueRange()) { + requiredProperties.insert(prop.propertyName, propKey); + } + + // collect required properties + auto mo = obj->metaObject(); + for (int i = 0; i < mo->propertyCount(); i++) { + auto prop = mo->property(i); + // nhlog::ui()->critical("Found prop {}", prop.name()); + // See https://bugreports.qt.io/browse/QTBUG-98846 + if (!prop.isRequired() && !requiredProperties.contains(prop.name())) + continue; + + if (auto role = nameToRole.find(prop.name()); role != nameToRole.end()) { + roleToPropIdx.insert(*role, i); + roles.emplace_back(*role); + + // nhlog::ui()->critical("Found prop {}, idx {}, role {}", prop.name(), i, *role); + } else { + nhlog::ui()->critical("Required property {} not found in model!", prop.name()); + } + } + + // nhlog::ui()->debug("Querying data for id {}", currentId.toStdString()); + chooser.room_->multiData(currentId, forReply ? chooser.eventId_ : QString(), roles); + + Qt::beginPropertyUpdateGroup(); + auto attached = qobject_cast<EventDelegateChooserAttachedType *>( + qmlAttachedPropertiesObject<EventDelegateChooser>(obj)); + Q_ASSERT(attached != nullptr); + attached->setIsReply(this->forReply); + + for (const auto &role : roles) { + const auto &roleName = roleNames[role.role()]; + // nhlog::ui()->critical("Setting role {}, {} to {}", + // role.role(), + // roleName.toStdString(), + // role.data().toString().toStdString()); + + // nhlog::ui()->critical("Setting {}", mo->property(roleToPropIdx[role.role()]).name()); + mo->property(roleToPropIdx[role.role()]).write(obj, role.data()); + + if (const auto &req = requiredProperties.find(roleName); req != requiredProperties.end()) + QQmlIncubatorPrivate::get(this)->requiredProperties()->remove(*req); + } + + Qt::endPropertyUpdateGroup(); + + // setInitialProperties(rolesToSet); + + auto update = + [this, obj, roleToPropIdx = std::move(roleToPropIdx)](const QList<int> &changedRoles) { + if (changedRoles.empty() || changedRoles.contains(TimelineModel::Roles::Type)) { + int type = chooser.room_ + ->dataById(currentId, + TimelineModel::Roles::Type, + forReply ? chooser.eventId_ : QString()) + .toInt(); + if (type != oldType) { + // nhlog::ui()->debug("Type changed!"); + reset(currentId); + return; + } + } + + std::vector<QModelRoleData> rolesToRequest; + + if (changedRoles.empty()) { + for (const auto role : + std::ranges::subrange(roleToPropIdx.keyBegin(), roleToPropIdx.keyEnd())) + rolesToRequest.emplace_back(role); + } else { + for (auto role : changedRoles) { + if (roleToPropIdx.contains(role)) { + rolesToRequest.emplace_back(role); + } + } + } + + if (rolesToRequest.empty()) + return; + + auto mo = obj->metaObject(); + chooser.room_->multiData( + currentId, forReply ? chooser.eventId_ : QString(), rolesToRequest); + + Qt::beginPropertyUpdateGroup(); + for (const auto &role : rolesToRequest) { + mo->property(roleToPropIdx[role.role()]).write(obj, role.data()); + } + Qt::endPropertyUpdateGroup(); + }; + + if (!forReply) { + auto row = chooser.room_->idToIndex(currentId); + auto connection = connect( + chooser.room_, + &QAbstractItemModel::dataChanged, + obj, + [row, update](const QModelIndex &topLeft, + const QModelIndex &bottomRight, + const QList<int> &changedRoles) { + if (row < topLeft.row() || row > bottomRight.row()) + return; + + update(changedRoles); + }, + Qt::QueuedConnection); + connect(&this->chooser, &EventDelegateChooser::destroyed, obj, [connection]() { + QObject::disconnect(connection); + }); + } +} + +void +EventDelegateChooser::DelegateIncubator::reset(QString id) +{ + if (!chooser.room_ || id.isEmpty()) + return; + + // nhlog::ui()->debug("Reset with id {}, reply {}", id.toStdString(), forReply); + + this->currentId = id; + + auto role = + chooser.room_ + ->dataById(id, TimelineModel::Roles::Type, forReply ? chooser.eventId_ : QString()) + .toInt(); + this->oldType = role; + + for (const auto choice : std::as_const(chooser.choices_)) { + const auto &choiceValue = choice->roleValues(); + if (choiceValue.contains(role) || choiceValue.empty()) { + // nhlog::ui()->debug( + // "Instantiating type: {}, c {}", (int)role, choiceValue.contains(role)); + + if (auto child = qobject_cast<QQuickItem *>(object())) { + child->setParentItem(nullptr); + } + + choice->delegate()->create(*this, QQmlEngine::contextForObject(&chooser)); + return; + } + } +} + +void +EventDelegateChooser::DelegateIncubator::statusChanged(QQmlIncubator::Status status) +{ + if (status == QQmlIncubator::Ready) { + auto child = qobject_cast<QQuickItem *>(object()); + if (child == nullptr) { + nhlog::ui()->error("Delegate has to be derived of Item!"); + return; + } + + child->setParentItem(&chooser); + QQmlEngine::setObjectOwnership(child, QQmlEngine::ObjectOwnership::CppOwnership); + + // connect(child, &QQuickItem::parentChanged, child, [child](QQuickItem *) { + // // QTBUG-115687 + // if (child->flags().testFlag(QQuickItem::ItemObservesViewport)) { + // nhlog::ui()->critical("SETTING OBSERVES VIEWPORT"); + // // Re-trigger the parent traversal to get subtreeTransformChangedEnabled turned + // on child->setFlag(QQuickItem::ItemObservesViewport); + // } + // }); + + if (forReply) + emit chooser.replyChanged(); + else + emit chooser.mainChanged(); + + chooser.polish(); + } else if (status == QQmlIncubator::Error) { + auto errors_ = errors(); + for (const auto &e : std::as_const(errors_)) + nhlog::ui()->error("Error instantiating delegate: {}", e.toString().toStdString()); + } +} + +void +EventDelegateChooser::updatePolish() +{ + auto mainChild = qobject_cast<QQuickItem *>(eventIncubator.object()); + auto replyChild = qobject_cast<QQuickItem *>(replyIncubator.object()); + + // nhlog::ui()->trace("POLISHING {}", (void *)this); + + auto layoutItem = [this](QQuickItem *item, int inset) { + if (item) { + QObject::disconnect(item, &QQuickItem::implicitWidthChanged, this, &QQuickItem::polish); + + auto attached = qobject_cast<EventDelegateChooserAttachedType *>( + qmlAttachedPropertiesObject<EventDelegateChooser>(item)); + Q_ASSERT(attached != nullptr); + + int maxWidth = maxWidth_ - inset; + + // in theory we could also reset the width, but that doesn't seem to work nicely for + // text areas because of how they cache it. + if (attached->maxWidth() > 0) + item->setWidth(attached->maxWidth()); + else + item->setWidth(maxWidth); + item->ensurePolished(); + auto width = item->implicitWidth(); + + if (width < 1 || width > maxWidth) + width = maxWidth; + + if (attached->maxWidth() > 0 && width > attached->maxWidth()) + width = attached->maxWidth(); + + if (attached->keepAspectRatio()) { + auto height = width * attached->aspectRatio(); + if (attached->maxHeight() && height > attached->maxHeight()) { + height = attached->maxHeight(); + width = height / attached->aspectRatio(); + } + + item->setHeight(height); + } + + item->setWidth(width); + item->ensurePolished(); + + QObject::connect(item, &QQuickItem::implicitWidthChanged, this, &QQuickItem::polish); + } + }; + + layoutItem(mainChild, mainInset_); + layoutItem(replyChild, replyInset_); +} + +void +EventDelegateChooserAttachedType::polishChooser() +{ + auto p = parent(); + if (p) { + auto chooser = qobject_cast<EventDelegateChooser *>(p->parent()); + if (chooser) { + chooser->polish(); + } + } +} diff --git a/src/timeline/EventDelegateChooser.h b/src/timeline/EventDelegateChooser.h new file mode 100644
index 00000000..df1953ab --- /dev/null +++ b/src/timeline/EventDelegateChooser.h
@@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <QAbstractItemModel> +#include <QQmlComponent> +#include <QQmlIncubator> +#include <QQmlListProperty> +#include <QQuickItem> +#include <QtCore/QObject> +#include <QtCore/QVariant> + +#include "TimelineModel.h" + +class EventDelegateChooserAttachedType : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool keepAspectRatio READ keepAspectRatio WRITE setKeepAspectRatio NOTIFY + keepAspectRatioChanged) + Q_PROPERTY(double aspectRatio READ aspectRatio WRITE setAspectRatio NOTIFY aspectRatioChanged) + Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged) + Q_PROPERTY(int maxHeight READ maxHeight WRITE setMaxHeight NOTIFY maxHeightChanged) + Q_PROPERTY(bool isReply READ isReply WRITE setIsReply NOTIFY isReplyChanged) + + QML_ANONYMOUS +public: + EventDelegateChooserAttachedType(QObject *parent) + : QObject(parent) + { + } + + bool keepAspectRatio() const { return keepAspectRatio_; } + void setKeepAspectRatio(bool fill) + { + if (fill != keepAspectRatio_) { + keepAspectRatio_ = fill; + emit keepAspectRatioChanged(); + polishChooser(); + } + } + + double aspectRatio() const { return aspectRatio_; } + void setAspectRatio(double fill) + { + aspectRatio_ = fill; + emit aspectRatioChanged(); + polishChooser(); + } + + int maxWidth() const { return maxWidth_; } + void setMaxWidth(int fill) + { + maxWidth_ = fill; + emit maxWidthChanged(); + polishChooser(); + } + + int maxHeight() const { return maxHeight_; } + void setMaxHeight(int fill) + { + maxHeight_ = fill; + emit maxHeightChanged(); + } + + bool isReply() const { return isReply_; } + void setIsReply(bool fill) + { + if (fill != isReply_) { + isReply_ = fill; + emit isReplyChanged(); + polishChooser(); + } + } + +signals: + void keepAspectRatioChanged(); + void aspectRatioChanged(); + void maxWidthChanged(); + void maxHeightChanged(); + void isReplyChanged(); + +private: + void polishChooser(); + + double aspectRatio_ = 1.; + int maxWidth_ = -1; + int maxHeight_ = -1; + bool keepAspectRatio_ = false; + bool isReply_ = false; +}; + +class EventDelegateChoice : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_CLASSINFO("DefaultProperty", "delegate") + +public: + Q_PROPERTY(QList<int> roleValues READ roleValues WRITE setRoleValues NOTIFY roleValuesChanged + REQUIRED FINAL) + Q_PROPERTY( + QQmlComponent *delegate READ delegate WRITE setDelegate NOTIFY delegateChanged REQUIRED FINAL) + + [[nodiscard]] QQmlComponent *delegate() const; + void setDelegate(QQmlComponent *delegate); + + [[nodiscard]] QList<int> roleValues() const; + void setRoleValues(const QList<int> &value); + +signals: + void delegateChanged(); + void roleValuesChanged(); + void changed(); + +private: + QList<int> roleValues_; + QQmlComponent *delegate_ = nullptr; +}; + +class EventDelegateChooser : public QQuickItem +{ + Q_OBJECT + QML_ELEMENT + Q_CLASSINFO("DefaultProperty", "choices") + + QML_ATTACHED(EventDelegateChooserAttachedType) + + Q_PROPERTY(QQmlListProperty<EventDelegateChoice> choices READ choices CONSTANT FINAL) + Q_PROPERTY(QQuickItem *main READ main NOTIFY mainChanged FINAL) + Q_PROPERTY(QQuickItem *reply READ reply NOTIFY replyChanged FINAL) + Q_PROPERTY(QString eventId READ eventId WRITE setEventId NOTIFY eventIdChanged REQUIRED FINAL) + Q_PROPERTY(QString replyTo READ replyTo WRITE setReplyTo NOTIFY replyToChanged REQUIRED FINAL) + Q_PROPERTY(TimelineModel *room READ room WRITE setRoom NOTIFY roomChanged REQUIRED FINAL) + Q_PROPERTY(bool sameWidth READ sameWidth WRITE setSameWidth NOTIFY sameWidthChanged) + Q_PROPERTY(int maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged) + Q_PROPERTY(int replyInset READ replyInset WRITE setReplyInset NOTIFY replyInsetChanged) + Q_PROPERTY(int mainInset READ mainInset WRITE setMainInset NOTIFY mainInsetChanged) + +public: + QQmlListProperty<EventDelegateChoice> choices(); + + [[nodiscard]] QQuickItem *main() const + { + return qobject_cast<QQuickItem *>(eventIncubator.object()); + } + [[nodiscard]] QQuickItem *reply() const + { + return qobject_cast<QQuickItem *>(replyIncubator.object()); + } + + bool sameWidth() const { return sameWidth_; } + void setSameWidth(bool width) + { + sameWidth_ = width; + emit sameWidthChanged(); + } + int maxWidth() const { return maxWidth_; } + void setMaxWidth(int width) + { + maxWidth_ = width; + emit maxWidthChanged(); + polish(); + } + + int replyInset() const { return replyInset_; } + void setReplyInset(int width) + { + replyInset_ = width; + emit replyInsetChanged(); + polish(); + } + + int mainInset() const { return mainInset_; } + void setMainInset(int width) + { + mainInset_ = width; + emit mainInsetChanged(); + polish(); + } + + void setRoom(TimelineModel *m) + { + if (m != room_) { + room_ = m; + emit roomChanged(); + + if (isComponentComplete()) { + eventIncubator.reset(eventId_); + replyIncubator.reset(replyId); + } + } + } + [[nodiscard]] TimelineModel *room() { return room_; } + + void setEventId(QString idx) + { + eventId_ = idx; + emit eventIdChanged(); + + if (isComponentComplete()) + eventIncubator.reset(eventId_); + } + [[nodiscard]] QString eventId() const { return eventId_; } + void setReplyTo(QString id) + { + replyId = id; + emit replyToChanged(); + + if (isComponentComplete()) + replyIncubator.reset(replyId); + } + [[nodiscard]] QString replyTo() const { return replyId; } + + void componentComplete() override; + + static EventDelegateChooserAttachedType *qmlAttachedProperties(QObject *object) + { + return new EventDelegateChooserAttachedType(object); + } + + void updatePolish() override; + +signals: + void mainChanged(); + void replyChanged(); + void roomChanged(); + void eventIdChanged(); + void replyToChanged(); + void sameWidthChanged(); + void maxWidthChanged(); + void replyInsetChanged(); + void mainInsetChanged(); + +private: + struct DelegateIncubator final : public QQmlIncubator + { + DelegateIncubator(EventDelegateChooser &parent, bool forReply) + : QQmlIncubator(QQmlIncubator::AsynchronousIfNested) + , chooser(parent) + , forReply(forReply) + { + } + void setInitialState(QObject *object) override; + void statusChanged(QQmlIncubator::Status status) override; + + void reset(QString id); + + EventDelegateChooser &chooser; + bool forReply; + QString currentId; + + QString instantiatedId; + int instantiatedRole = -1; + QAbstractItemModel *instantiatedModel = nullptr; + int oldType = -1; + }; + + QVariant roleValue_; + QList<EventDelegateChoice *> choices_; + DelegateIncubator eventIncubator{*this, false}; + DelegateIncubator replyIncubator{*this, true}; + TimelineModel *room_{nullptr}; + QString eventId_; + QString replyId; + bool sameWidth_ = false; + int maxWidth_ = 400; + int replyInset_ = 0; + int mainInset_ = 0; + + static void appendChoice(QQmlListProperty<EventDelegateChoice> *, EventDelegateChoice *); + static qsizetype choiceCount(QQmlListProperty<EventDelegateChoice> *); + static EventDelegateChoice *choice(QQmlListProperty<EventDelegateChoice> *, qsizetype index); + static void clearChoices(QQmlListProperty<EventDelegateChoice> *); +}; diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
index 63b67474..3db70f77 100644 --- a/src/timeline/EventStore.cpp +++ b/src/timeline/EventStore.cpp
@@ -800,7 +800,7 @@ EventStore::enableKeyRequests(bool suppressKeyRequests_) { if (!suppressKeyRequests_) { auto keys = decryptedEvents_.keys(); - for (const auto &key : qAsConst(keys)) + for (const auto &key : std::as_const(keys)) if (key.room == this->room_id_) decryptedEvents_.remove(key); suppressKeyRequests = false; @@ -843,8 +843,8 @@ EventStore::get(const std::string &id, nhlog::net()->error( "Failed to retrieve event with id {}, which was " "requested to show the replyTo for event {}", - relatedTo, - id); + id, + relatedTo); return; } emit eventFetched(id, relatedTo, timeline); diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index a371e2b4..fcec8e9c 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp
@@ -491,7 +491,7 @@ InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbow QString body; bool firstLine = true; auto lines = QStringView(related.quoted_body).split(u'\n'); - for (auto line : qAsConst(lines)) { + for (auto line : std::as_const(lines)) { if (firstLine) { firstLine = false; body = QStringLiteral("> <%1> %2\n").arg(related.quoted_user, line); diff --git a/src/timeline/RoomlistModel.cpp b/src/timeline/RoomlistModel.cpp
index 8d8d2977..5ea6f8c8 100644 --- a/src/timeline/RoomlistModel.cpp +++ b/src/timeline/RoomlistModel.cpp
@@ -340,7 +340,7 @@ RoomlistModel::addRoom(const QString &room_id, bool suppressInsertNotification) int total_unread_msgs = 0; - for (const auto &room : qAsConst(models)) { + for (const auto &room : std::as_const(models)) { if (!room.isNull() && !room->isSpace()) total_unread_msgs += room->notificationCount(); } @@ -541,7 +541,7 @@ RoomlistModel::sync(const mtx::responses::Sync &sync_) if (auto t = std::get_if<mtx::events::EphemeralEvent<mtx::events::ephemeral::Typing>>( &ev)) { - std::vector<QString> typing; + QStringList typing; typing.reserve(t->content.user_ids.size()); for (const auto &user : t->content.user_ids) { if (user != http::client()->user_id().to_string()) @@ -948,7 +948,7 @@ FilteredRoomlistModel::updateHiddenTagsAndSpaces() hideDMs = false; auto hidden = UserSettings::instance()->hiddenTags(); - for (const auto &t : qAsConst(hidden)) { + for (const auto &t : std::as_const(hidden)) { if (t.startsWith(u"tag:")) hiddenTags.push_back(t.mid(4)); else if (t.startsWith(u"space:")) diff --git a/src/timeline/TimelineFilter.cpp b/src/timeline/TimelineFilter.cpp
index 6f2f9e7a..c2d9e31b 100644 --- a/src/timeline/TimelineFilter.cpp +++ b/src/timeline/TimelineFilter.cpp
@@ -163,14 +163,20 @@ TimelineFilter::setSource(TimelineModel *s) this->setSourceModel(s); - connect(s, &TimelineModel::currentIndexChanged, this, &TimelineFilter::currentIndexChanged); - connect( - s, &TimelineModel::fetchedMore, this, &TimelineFilter::fetchAgain, Qt::QueuedConnection); - connect(s, - &TimelineModel::dataChanged, - this, - &TimelineFilter::sourceDataChanged, - Qt::QueuedConnection); + if (s) { + connect( + s, &TimelineModel::currentIndexChanged, this, &TimelineFilter::currentIndexChanged); + connect(s, + &TimelineModel::fetchedMore, + this, + &TimelineFilter::fetchAgain, + Qt::QueuedConnection); + connect(s, + &TimelineModel::dataChanged, + this, + &TimelineFilter::sourceDataChanged, + Qt::QueuedConnection); + } // reset the search index a second time just to be safe. incrementalSearchIndex = 0; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index e13b56d7..d85a9516 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp
@@ -534,6 +534,7 @@ TimelineModel::roleNames() const {IsOnlyEmoji, "isOnlyEmoji"}, {Body, "body"}, {FormattedBody, "formattedBody"}, + {FormattedStateEvent, "formattedStateEvent"}, {IsSender, "isSender"}, {UserId, "userId"}, {UserName, "userName"}, @@ -562,6 +563,7 @@ TimelineModel::roleNames() const {ReplyTo, "replyTo"}, {ThreadId, "threadId"}, {Reactions, "reactions"}, + {Room, "room"}, {RoomId, "roomId"}, {RoomName, "roomName"}, {RoomTopic, "roomTopic"}, @@ -601,12 +603,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r case UserName: return QVariant(displayName(QString::fromStdString(acc::sender(event)))); case UserPowerlevel: { - return static_cast<qlonglong>(mtx::events::state::PowerLevels{ - cache::client() - ->getStateEvent<mtx::events::state::PowerLevels>(room_id_.toStdString()) - .value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{}) - .content} - .user_level(acc::sender(event))); + return static_cast<qlonglong>( + permissions_.powerlevelEvent().user_level(acc::sender(event))); } case Day: { @@ -694,8 +692,90 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r formattedBody_.replace(curImg, imgReplacement); } + if (auto effectMessage = + std::get_if<mtx::events::RoomEvent<mtx::events::msg::ElementEffect>>(&event)) { + if (effectMessage->content.msgtype == std::string_view("nic.custom.confetti")) { + formattedBody_.append(QUtf8StringView(u8"🎊")); + } else if (effectMessage->content.msgtype == + std::string_view("io.element.effect.rainfall")) { + formattedBody_.append(QUtf8StringView(u8"🌧️")); + } + } + return QVariant(utils::replaceEmoji(utils::linkifyMessage(formattedBody_))); } + case FormattedStateEvent: { + if (mtx::accessors::is_state_event(event)) { + return std::visit( + [this](const auto &e) { + constexpr auto t = mtx::events::state_content_to_type<decltype(e.content)>; + if constexpr (t == mtx::events::EventType::RoomServerAcl) + return tr("%1 changed which servers are allowed in this room.") + .arg(displayName(QString::fromStdString(e.sender))); + else if constexpr (t == mtx::events::EventType::RoomName) { + if (e.content.name.empty()) + return tr("%1 removed the room name.") + .arg(displayName(QString::fromStdString(e.sender))); + else + return tr("%1 changed the room name to: %2") + .arg(displayName(QString::fromStdString(e.sender))) + .arg(QString::fromStdString(e.content.name).toHtmlEscaped()); + } else if constexpr (t == mtx::events::EventType::RoomTopic) { + if (e.content.topic.empty()) + return tr("%1 removed the topic.") + .arg(displayName(QString::fromStdString(e.sender))); + else + return tr("%1 changed the topic to: %2") + .arg(displayName(QString::fromStdString(e.sender))) + .arg(QString::fromStdString(e.content.topic).toHtmlEscaped()); + } else if constexpr (t == mtx::events::EventType::RoomAvatar) { + if (e.content.url.starts_with("mxc://")) + return tr("%1 changed the room avatar to: %2") + .arg(displayName(QString::fromStdString(e.sender))) + .arg(QStringLiteral("<img height=\"32\" src=\"%1\">") + .arg(QUrl::toPercentEncoding( + QString::fromStdString(e.content.url)))); + else + return tr("%1 removed the room avatar.") + .arg(displayName(QString::fromStdString(e.sender))); + } else if constexpr (t == mtx::events::EventType::RoomPinnedEvents) + return tr("%1 changed the pinned messages.") + .arg(displayName(QString::fromStdString(e.sender))); + else if constexpr (t == mtx::events::EventType::ImagePackInRoom) + formatImagePackEvent(e); + else if constexpr (t == mtx::events::EventType::RoomCanonicalAlias) + return tr("%1 changed the addresses for this room.") + .arg(displayName(QString::fromStdString(e.sender))); + else if constexpr (t == mtx::events::EventType::SpaceParent) + return tr("%1 changed the parent communities for this room.") + .arg(displayName(QString::fromStdString(e.sender))); + else if constexpr (t == mtx::events::EventType::RoomCreate) + return tr("%1 created and configured room: %2") + .arg(displayName(QString::fromStdString(e.sender))) + .arg(room_id_); + else if constexpr (t == mtx::events::EventType::RoomPowerLevels) + return formatPowerLevelEvent(e); + else if constexpr (t == mtx::events::EventType::PolicyRuleRoom) + return formatPolicyRule(QString::fromStdString(e.event_id)); + else if constexpr (t == mtx::events::EventType::PolicyRuleUser) + return formatPolicyRule(QString::fromStdString(e.event_id)); + else if constexpr (t == mtx::events::EventType::PolicyRuleServer) + return formatPolicyRule(QString::fromStdString(e.event_id)); + else if constexpr (t == mtx::events::EventType::RoomHistoryVisibility) + return formatHistoryVisibilityEvent(e); + else if constexpr (t == mtx::events::EventType::RoomGuestAccess) + return formatGuestAccessEvent(e); + else if constexpr (t == mtx::events::EventType::RoomMember) + return formatMemberEvent(e); + + return tr("%1 changed unknown state event %2.") + .arg(displayName(QString::fromStdString(e.sender))) + .arg(QString::fromStdString(to_string(e.type))); + }, + event); + } + return QString(); + } case Url: return QVariant(QString::fromStdString(url(event))); case ThumbnailUrl: @@ -830,6 +910,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r auto id = relations(event).replaces().value_or(event_id(event)); return QVariant::fromValue(events.reactions(id)); } + case Room: + return QVariant::fromValue(this); case RoomId: return QVariant(room_id_); case RoomName: @@ -909,8 +991,11 @@ TimelineModel::data(const QModelIndex &index, int role) const void TimelineModel::multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSpan) const { - if (index.row() < 0 && index.row() >= rowCount()) + if (index.row() < 0 && index.row() >= rowCount()) { + for (QModelRoleData &roleData : roleDataSpan) + roleData.clearData(); return; + } // HACK(Nico): fetchMore likes to break with dynamically sized delegates and reuseItems if (index.row() + 1 == rowCount() && !m_paginationInProgress) @@ -918,8 +1003,35 @@ TimelineModel::multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSp auto event = events.get(rowCount() - index.row() - 1); - if (!event) + if (!event) { + for (QModelRoleData &roleData : roleDataSpan) + roleData.clearData(); + return; + } + + for (QModelRoleData &roleData : roleDataSpan) { + roleData.setData(data(*event, roleData.role())); + } +} + +void +TimelineModel::multiData(const QString &id, + const QString &relatedTo, + QModelRoleDataSpan roleDataSpan) const +{ + if (id.isEmpty()) { + for (QModelRoleData &roleData : roleDataSpan) + roleData.clearData(); + return; + } + + auto event = events.get(id.toStdString(), relatedTo.toStdString()); + + if (!event) { + for (QModelRoleData &roleData : roleDataSpan) + roleData.clearData(); return; + } for (QModelRoleData &roleData : roleDataSpan) { int role = roleData.role(); @@ -2208,7 +2320,7 @@ TimelineModel::markSpecialEffectsDone() } QString -TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor &bg) +TimelineModel::formatTypingUsers(const QStringList &users, const QColor &bg) { QString temp = tr("%1 and %2 are typing.", @@ -2255,7 +2367,7 @@ TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor }; uidWithoutLast.reserve(static_cast<int>(users.size())); - for (size_t i = 0; i + 1 < users.size(); i++) { + for (qsizetype i = 0; i + 1 < users.size(); i++) { uidWithoutLast.append(formatUser(users[i])); } @@ -2300,20 +2412,13 @@ TimelineModel::formatJoinRuleEvent(const QString &id) } QString -TimelineModel::formatGuestAccessEvent(const QString &id) +TimelineModel::formatGuestAccessEvent( + const mtx::events::StateEvent<mtx::events::state::GuestAccess> &event) const { - auto e = events.get(id.toStdString(), ""); - if (!e) - return {}; - - auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(e); - if (!event) - return {}; - - QString user = QString::fromStdString(event->sender); + QString user = QString::fromStdString(event.sender); QString name = utils::replaceEmoji(displayName(user)); - switch (event->content.guest_access) { + switch (event.content.guest_access) { case mtx::events::state::AccessState::CanJoin: return tr("%1 made the room open to guests.").arg(name); case mtx::events::state::AccessState::Forbidden: @@ -2324,21 +2429,13 @@ TimelineModel::formatGuestAccessEvent(const QString &id) } QString -TimelineModel::formatHistoryVisibilityEvent(const QString &id) +TimelineModel::formatHistoryVisibilityEvent( + const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const { - auto e = events.get(id.toStdString(), ""); - if (!e) - return {}; - - auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(e); - - if (!event) - return {}; - - QString user = QString::fromStdString(event->sender); + QString user = QString::fromStdString(event.sender); QString name = utils::replaceEmoji(displayName(user)); - switch (event->content.history_visibility) { + switch (event.content.history_visibility) { case mtx::events::state::Visibility::WorldReadable: return tr("%1 made the room history world readable. Events may be now read by " "non-joined people.") @@ -2356,32 +2453,25 @@ TimelineModel::formatHistoryVisibilityEvent(const QString &id) } QString -TimelineModel::formatPowerLevelEvent(const QString &id) +TimelineModel::formatPowerLevelEvent( + const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const { - auto e = events.get(id.toStdString(), ""); - if (!e) - return {}; - - auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(e); - if (!event) - return QString(); - mtx::events::StateEvent<mtx::events::state::PowerLevels> const *prevEvent = nullptr; - if (!event->unsigned_data.replaces_state.empty()) { - auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id); + if (!event.unsigned_data.replaces_state.empty()) { + auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id); if (tempPrevEvent) { prevEvent = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(tempPrevEvent); } } - QString user = QString::fromStdString(event->sender); + QString user = QString::fromStdString(event.sender); QString sender_name = utils::replaceEmoji(displayName(user)); // Get the rooms levels for redactions and powerlevel changes to determine "Administrator" and // "Moderator" powerlevels. - auto administrator_power_level = event->content.state_level("m.room.power_levels"); - auto moderator_power_level = event->content.redact; - auto default_powerlevel = event->content.users_default; + auto administrator_power_level = event.content.state_level("m.room.power_levels"); + auto moderator_power_level = event.content.redact; + auto default_powerlevel = event.content.users_default; if (!prevEvent) return tr("%1 has changed the room's permissions.").arg(sender_name); @@ -2391,7 +2481,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id) auto numberOfAffected = 0; // We do only compare to people with explicit PL. Usually others are not going to be // affected either way and this is cheaper to iterate over. - for (auto const &[mxid, currentPowerlevel] : event->content.users) { + for (auto const &[mxid, currentPowerlevel] : event.content.users) { if (currentPowerlevel == newPowerlevelSetting && prevEvent->content.user_level(mxid) < newPowerlevelSetting) { numberOfAffected++; @@ -2405,16 +2495,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id) QStringList resultingMessage{}; // These affect only a few people. Therefor we can print who is affected. - if (event->content.kick != prevEvent->content.kick) { + if (event.content.kick != prevEvent->content.kick) { auto default_message = tr("%1 has changed the room's kick powerlevel from %2 to %3.") .arg(sender_name) .arg(prevEvent->content.kick) - .arg(event->content.kick); + .arg(event.content.kick); // We only calculate affected users if we change to a level above the default users PL // to not accidentally have a DoS vector - if (event->content.kick > default_powerlevel) { - auto [affected, number_of_affected] = calc_affected(event->content.kick); + if (event.content.kick > default_powerlevel) { + auto [affected, number_of_affected] = calc_affected(event.content.kick); if (number_of_affected != 0) { auto true_affected_rest = number_of_affected - affected.size(); @@ -2436,16 +2526,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id) } } - if (event->content.redact != prevEvent->content.redact) { + if (event.content.redact != prevEvent->content.redact) { auto default_message = tr("%1 has changed the room's redact powerlevel from %2 to %3.") .arg(sender_name) .arg(prevEvent->content.redact) - .arg(event->content.redact); + .arg(event.content.redact); // We only calculate affected users if we change to a level above the default users PL // to not accidentally have a DoS vector - if (event->content.redact > default_powerlevel) { - auto [affected, number_of_affected] = calc_affected(event->content.redact); + if (event.content.redact > default_powerlevel) { + auto [affected, number_of_affected] = calc_affected(event.content.redact); if (number_of_affected != 0) { auto true_affected_rest = number_of_affected - affected.size(); @@ -2468,16 +2558,16 @@ TimelineModel::formatPowerLevelEvent(const QString &id) } } - if (event->content.ban != prevEvent->content.ban) { + if (event.content.ban != prevEvent->content.ban) { auto default_message = tr("%1 has changed the room's ban powerlevel from %2 to %3.") .arg(sender_name) .arg(prevEvent->content.ban) - .arg(event->content.ban); + .arg(event.content.ban); // We only calculate affected users if we change to a level above the default users PL // to not accidentally have a DoS vector - if (event->content.ban > default_powerlevel) { - auto [affected, number_of_affected] = calc_affected(event->content.ban); + if (event.content.ban > default_powerlevel) { + auto [affected, number_of_affected] = calc_affected(event.content.ban); if (number_of_affected != 0) { auto true_affected_rest = number_of_affected - affected.size(); @@ -2499,17 +2589,17 @@ TimelineModel::formatPowerLevelEvent(const QString &id) } } - if (event->content.state_default != prevEvent->content.state_default) { + if (event.content.state_default != prevEvent->content.state_default) { auto default_message = tr("%1 has changed the room's state_default powerlevel from %2 to %3.") .arg(sender_name) .arg(prevEvent->content.state_default) - .arg(event->content.state_default); + .arg(event.content.state_default); // We only calculate affected users if we change to a level above the default users PL // to not accidentally have a DoS vector - if (event->content.state_default > default_powerlevel) { - auto [affected, number_of_affected] = calc_affected(event->content.kick); + if (event.content.state_default > default_powerlevel) { + auto [affected, number_of_affected] = calc_affected(event.content.kick); if (number_of_affected != 0) { auto true_affected_rest = number_of_affected - affected.size(); @@ -2533,42 +2623,42 @@ TimelineModel::formatPowerLevelEvent(const QString &id) // These affect potentially the whole room. We there for do not calculate who gets affected // by this to prevent huge lists of people. - if (event->content.invite != prevEvent->content.invite) { + if (event.content.invite != prevEvent->content.invite) { resultingMessage.append(tr("%1 has changed the room's invite powerlevel from %2 to %3.") .arg(sender_name, QString::number(prevEvent->content.invite), - QString::number(event->content.invite))); + QString::number(event.content.invite))); } - if (event->content.events_default != prevEvent->content.events_default) { - if ((event->content.events_default > default_powerlevel) && + if (event.content.events_default != prevEvent->content.events_default) { + if ((event.content.events_default > default_powerlevel) && prevEvent->content.events_default <= default_powerlevel) { resultingMessage.append( tr("%1 has changed the room's events_default powerlevel from %2 to %3. New " "users can now not send any events.") .arg(sender_name, QString::number(prevEvent->content.events_default), - QString::number(event->content.events_default))); - } else if ((event->content.events_default < prevEvent->content.events_default) && - (event->content.events_default < default_powerlevel) && + QString::number(event.content.events_default))); + } else if ((event.content.events_default < prevEvent->content.events_default) && + (event.content.events_default < default_powerlevel) && (prevEvent->content.events_default > default_powerlevel)) { resultingMessage.append( tr("%1 has changed the room's events_default powerlevel from %2 to %3. New " "users can now send events that are not otherwise restricted.") .arg(sender_name, QString::number(prevEvent->content.events_default), - QString::number(event->content.events_default))); + QString::number(event.content.events_default))); } else { resultingMessage.append( tr("%1 has changed the room's events_default powerlevel from %2 to %3.") .arg(sender_name, QString::number(prevEvent->content.events_default), - QString::number(event->content.events_default))); + QString::number(event.content.events_default))); } } // Compare if a Powerlevel of a user changed - for (auto const &[mxid, powerlevel] : event->content.users) { + for (auto const &[mxid, powerlevel] : event.content.users) { auto nameOfChangedUser = utils::replaceEmoji(displayName(QString::fromStdString(mxid))); if (prevEvent->content.user_level(mxid) != powerlevel) { if (powerlevel >= administrator_power_level) { @@ -2593,7 +2683,7 @@ TimelineModel::formatPowerLevelEvent(const QString &id) } // Handle added/removed/changed event type - for (auto const &[event_type, powerlevel] : event->content.events) { + for (auto const &[event_type, powerlevel] : event.content.events) { auto prev_not_present = prevEvent->content.events.find(event_type) == prevEvent->content.events.end(); @@ -2632,26 +2722,19 @@ TimelineModel::formatPowerLevelEvent(const QString &id) } QString -TimelineModel::formatImagePackEvent(const QString &id) +TimelineModel::formatImagePackEvent( + const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const { - auto e = events.get(id.toStdString(), ""); - if (!e) - return {}; - - auto event = std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(e); - if (!event) - return {}; - mtx::events::StateEvent<mtx::events::msc2545::ImagePack> const *prevEvent = nullptr; - if (!event->unsigned_data.replaces_state.empty()) { - auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id); + if (!event.unsigned_data.replaces_state.empty()) { + auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id); if (tempPrevEvent) { prevEvent = std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(tempPrevEvent); } } - const auto &newImages = event->content.images; + const auto &newImages = event.content.images; const auto oldImages = prevEvent ? prevEvent->content.images : decltype(newImages){}; auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent(); @@ -2674,12 +2757,12 @@ TimelineModel::formatImagePackEvent(const QString &id) auto added = calcChange(newImages, oldImages); auto removed = calcChange(oldImages, newImages); - auto sender = utils::replaceEmoji(displayName(QString::fromStdString(event->sender))); + auto sender = utils::replaceEmoji(displayName(QString::fromStdString(event.sender))); const auto packId = [&event]() -> QString { - if (event->content.pack && !event->content.pack->display_name.empty()) { - return event->content.pack->display_name.c_str(); - } else if (!event->state_key.empty()) { - return event->state_key.c_str(); + if (event.content.pack && !event.content.pack->display_name.empty()) { + return event.content.pack->display_name.c_str(); + } else if (!event.state_key.empty()) { + return event.state_key.c_str(); } return tr("(empty)"); }(); @@ -2704,7 +2787,7 @@ TimelineModel::formatImagePackEvent(const QString &id) } QString -TimelineModel::formatPolicyRule(const QString &id) +TimelineModel::formatPolicyRule(const QString &id) const { auto idStr = id.toStdString(); auto e = events.get(idStr, ""); @@ -2905,34 +2988,27 @@ TimelineModel::joinReplacementRoom(const QString &id) } QString -TimelineModel::formatMemberEvent(const QString &id) +TimelineModel::formatMemberEvent( + const mtx::events::StateEvent<mtx::events::state::Member> &event) const { - auto e = events.get(id.toStdString(), ""); - if (!e) - return {}; - - auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e); - if (!event) - return {}; - mtx::events::StateEvent<mtx::events::state::Member> const *prevEvent = nullptr; - if (!event->unsigned_data.replaces_state.empty()) { - auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id); + if (!event.unsigned_data.replaces_state.empty()) { + auto tempPrevEvent = events.get(event.unsigned_data.replaces_state, event.event_id); if (tempPrevEvent) { prevEvent = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(tempPrevEvent); } } - QString user = QString::fromStdString(event->state_key); + QString user = QString::fromStdString(event.state_key); QString name = utils::replaceEmoji(displayName(user)); QString rendered; - QString sender = QString::fromStdString(event->sender); + QString sender = QString::fromStdString(event.sender); QString senderName = utils::replaceEmoji(displayName(sender)); // see table https://matrix.org/docs/spec/client_server/latest#m-room-member using namespace mtx::events::state; - switch (event->content.membership) { + switch (event.content.membership) { case Membership::Invite: rendered = tr("%1 invited %2.").arg(senderName, name); break; @@ -2941,9 +3017,8 @@ TimelineModel::formatMemberEvent(const QString &id) QString oldName = utils::replaceEmoji( QString::fromStdString(prevEvent->content.display_name).toHtmlEscaped()); - bool displayNameChanged = - prevEvent->content.display_name != event->content.display_name; - bool avatarChanged = prevEvent->content.avatar_url != event->content.avatar_url; + bool displayNameChanged = prevEvent->content.display_name != event.content.display_name; + bool avatarChanged = prevEvent->content.avatar_url != event.content.avatar_url; if (displayNameChanged && avatarChanged) rendered = tr("%1 has changed their avatar and changed their " @@ -2958,30 +3033,30 @@ TimelineModel::formatMemberEvent(const QString &id) // the case of nothing changed but join follows join shouldn't happen, so // just show it as join } else { - if (event->content.join_authorised_via_users_server.empty()) + if (event.content.join_authorised_via_users_server.empty()) rendered = tr("%1 joined.").arg(name); else rendered = tr("%1 joined via authorisation from %2's server.") .arg(name, - QString::fromStdString(event->content.join_authorised_via_users_server)); + QString::fromStdString(event.content.join_authorised_via_users_server)); } break; case Membership::Leave: if (!prevEvent || prevEvent->content.membership == Membership::Join) { - if (event->state_key == event->sender) + if (event.state_key == event.sender) rendered = tr("%1 left the room.").arg(name); else rendered = tr("%2 kicked %1.").arg(name, senderName); } else if (prevEvent->content.membership == Membership::Invite) { - if (event->state_key == event->sender) + if (event.state_key == event.sender) rendered = tr("%1 rejected their invite.").arg(name); else rendered = tr("%2 revoked the invite to %1.").arg(name, senderName); } else if (prevEvent->content.membership == Membership::Ban) { rendered = tr("%2 unbanned %1.").arg(name, senderName); } else if (prevEvent->content.membership == Membership::Knock) { - if (event->state_key == event->sender) + if (event.state_key == event.sender) rendered = tr("%1 redacted their knock.").arg(name); else rendered = tr("%2 rejected the knock from %1.").arg(name, senderName); @@ -3000,8 +3075,8 @@ TimelineModel::formatMemberEvent(const QString &id) break; } - if (event->content.reason != "") { - rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event->content.reason)); + if (event.content.reason != "") { + rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event.content.reason)); } return rendered; diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index c8947891..eefe921f 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h
@@ -199,8 +199,8 @@ class TimelineModel final : public QAbstractListModel QML_UNCREATABLE("") Q_PROPERTY(int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) - Q_PROPERTY(std::vector<QString> typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY - typingUsersChanged) + Q_PROPERTY( + QStringList typingUsers READ typingUsers WRITE updateTypingUsers NOTIFY typingUsersChanged) Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged) Q_PROPERTY(QString reply READ reply WRITE setReply NOTIFY replyChanged RESET resetReply) Q_PROPERTY(QString edit READ edit WRITE setEdit NOTIFY editChanged RESET resetEdit) @@ -238,6 +238,7 @@ public: IsOnlyEmoji, Body, FormattedBody, + FormattedStateEvent, IsSender, UserId, UserName, @@ -266,6 +267,7 @@ public: ReplyTo, ThreadId, Reactions, + Room, RoomId, RoomName, RoomTopic, @@ -286,6 +288,8 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; void multiData(const QModelIndex &index, QModelRoleDataSpan roleDataSpan) const override; + void + multiData(const QString &id, const QString &relatedTo, QModelRoleDataSpan roleDataSpan) const; QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const; Q_INVOKABLE QVariant dataById(const QString &id, int role, const QString &relatedTo); Q_INVOKABLE QVariant dataByIndex(int i, int role = Qt::DisplayRole) const @@ -302,17 +306,22 @@ public: Q_INVOKABLE QString displayName(const QString &id) const; Q_INVOKABLE QString avatarUrl(const QString &id) const; Q_INVOKABLE QString formatDateSeparator(QDate date) const; - Q_INVOKABLE QString formatTypingUsers(const std::vector<QString> &users, const QColor &bg); + Q_INVOKABLE QString formatTypingUsers(const QStringList &users, const QColor &bg); Q_INVOKABLE bool showAcceptKnockButton(const QString &id); Q_INVOKABLE void acceptKnock(const QString &id); Q_INVOKABLE void joinReplacementRoom(const QString &id); - Q_INVOKABLE QString formatMemberEvent(const QString &id); + Q_INVOKABLE QString + formatMemberEvent(const mtx::events::StateEvent<mtx::events::state::Member> &event) const; Q_INVOKABLE QString formatJoinRuleEvent(const QString &id); - Q_INVOKABLE QString formatHistoryVisibilityEvent(const QString &id); - Q_INVOKABLE QString formatGuestAccessEvent(const QString &id); - Q_INVOKABLE QString formatPowerLevelEvent(const QString &id); - Q_INVOKABLE QString formatImagePackEvent(const QString &id); - Q_INVOKABLE QString formatPolicyRule(const QString &id); + QString formatHistoryVisibilityEvent( + const mtx::events::StateEvent<mtx::events::state::HistoryVisibility> &event) const; + QString + formatGuestAccessEvent(const mtx::events::StateEvent<mtx::events::state::GuestAccess> &) const; + QString formatPowerLevelEvent( + const mtx::events::StateEvent<mtx::events::state::PowerLevels> &event) const; + QString formatImagePackEvent( + const mtx::events::StateEvent<mtx::events::msc2545::ImagePack> &event) const; + Q_INVOKABLE QString formatPolicyRule(const QString &id) const; Q_INVOKABLE QVariantMap formatRedactedEvent(const QString &id); Q_INVOKABLE void viewRawMessage(const QString &id); @@ -396,14 +405,14 @@ public slots: void lastReadIdOnWindowFocus(); void checkAfterFetch(); QVariantMap getDump(const QString &eventId, const QString &relatedTo) const; - void updateTypingUsers(const std::vector<QString> &users) + void updateTypingUsers(const QStringList &users) { if (this->typingUsers_ != users) { this->typingUsers_ = users; emit typingUsersChanged(typingUsers_); } } - std::vector<QString> typingUsers() const { return typingUsers_; } + QStringList typingUsers() const { return typingUsers_; } bool paginationInProgress() const { return m_paginationInProgress; } QString reply() const { return reply_; } void setReply(const QString &newReply); @@ -462,7 +471,7 @@ signals: void redactionFailed(QString id); void mediaCached(QString mxcUrl, QString cacheUrl); void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo); - void typingUsersChanged(std::vector<QString> users); + void typingUsersChanged(QStringList users); void replyChanged(QString reply); void editChanged(QString reply); void threadChanged(QString id); @@ -523,7 +532,7 @@ private: QString currentId, currentReadId; QString reply_, edit_, thread_; QString textBeforeEdit, replyBeforeEdit; - std::vector<QString> typingUsers_; + QStringList typingUsers_; TimelineViewManager *manager_; diff --git a/src/ui/MxcAnimatedImage.cpp b/src/ui/MxcAnimatedImage.cpp
index 14f5dbd8..ffe54c71 100644 --- a/src/ui/MxcAnimatedImage.cpp +++ b/src/ui/MxcAnimatedImage.cpp
@@ -102,10 +102,12 @@ MxcAnimatedImage::startDownload() if (buffer.bytesAvailable() < 4LL * 1024 * 1024 * 1024) // cache images smaller than 4MB in RAM movie.setCacheMode(QMovie::CacheAll); - if (play_) + if (play_ && movie.frameCount() > 1) movie.start(); - else + else { movie.jumpToFrame(0); + movie.setPaused(true); + } emit loadedChanged(); update(); }); @@ -173,6 +175,9 @@ MxcAnimatedImage::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeD if (!imageDirty) return oldNode; + if (clipRect().isEmpty()) + return oldNode; + imageDirty = false; QSGImageNode *n = static_cast<QSGImageNode *>(oldNode); if (!n) { diff --git a/src/ui/MxcAnimatedImage.h b/src/ui/MxcAnimatedImage.h
index c9f89764..1f2c0b74 100644 --- a/src/ui/MxcAnimatedImage.h +++ b/src/ui/MxcAnimatedImage.h
@@ -29,6 +29,7 @@ public: connect(this, &MxcAnimatedImage::roomChanged, &MxcAnimatedImage::startDownload); connect(&movie, &QMovie::frameChanged, this, &MxcAnimatedImage::newFrame); setFlag(QQuickItem::ItemHasContents); + setFlag(QQuickItem::ItemObservesViewport); // setAcceptHoverEvents(true); } @@ -55,7 +56,12 @@ public: { if (play_ != newPlay) { play_ = newPlay; - movie.setPaused(!play_); + if (movie.frameCount() > 1) + movie.setPaused(!play_); + else { + movie.jumpToFrame(0); + movie.setPaused(true); + } emit playChanged(); } } @@ -77,7 +83,8 @@ private slots: { currentFrame = frame; imageDirty = true; - update(); + if (!clipRect().isEmpty()) + update(); } private: diff --git a/src/ui/NhekoDropArea.cpp b/src/ui/NhekoDropArea.cpp
index 63c9aa6f..348ef5d8 100644 --- a/src/ui/NhekoDropArea.cpp +++ b/src/ui/NhekoDropArea.cpp
@@ -38,6 +38,7 @@ NhekoDropArea::dropEvent(QDropEvent *event) auto model = ChatPage::instance()->timelineManager()->rooms()->getRoomById(roomid_); if (model) { model->input()->insertMimeData(event->mimeData()); + ChatPage::instance()->timelineManager()->focusMessageInput(); } } } diff --git a/src/ui/RoomSettings.cpp b/src/ui/RoomSettings.cpp
index 769f2c8d..5f4184b3 100644 --- a/src/ui/RoomSettings.cpp +++ b/src/ui/RoomSettings.cpp
@@ -728,7 +728,7 @@ RoomSettingsAllowedRoomsModel::RoomSettingsAllowedRoomsModel(RoomSettings *paren this->listedRoomIds = QStringList(parentSpaces.begin(), parentSpaces.end()); - for (const auto &e : qAsConst(this->allowedRoomIds)) { + for (const auto &e : std::as_const(this->allowedRoomIds)) { if (!this->parentSpaces.count(e)) this->listedRoomIds.push_back(e); } diff --git a/src/voip/CallManager.cpp b/src/voip/CallManager.cpp
index 5479ba31..46679e71 100644 --- a/src/voip/CallManager.cpp +++ b/src/voip/CallManager.cpp
@@ -92,7 +92,8 @@ CallManager::CallManager(QObject *parent) if (QGuiApplication::platformName() != QStringLiteral("wayland")) { // Selected by default screenShareType_ = ScreenShareType::X11; - std::swap(screenShareTypes_[0], screenShareTypes_[1]); + if (screenShareTypes_.size() >= 2) + std::swap(screenShareTypes_[0], screenShareTypes_[1]); } } #endif