diff options
author | Jussi Kuokkanen <jussi.kuokkanen@protonmail.com> | 2020-08-28 23:35:40 +0300 |
---|---|---|
committer | Jussi Kuokkanen <jussi.kuokkanen@protonmail.com> | 2020-08-28 23:35:40 +0300 |
commit | 5e344d2685c3efc191d53534cdcdc994c5d9463d (patch) | |
tree | 62a1ed20dc3b95a8399323297e7ab1dd93ece6e4 | |
parent | add emoji completer to text input (diff) | |
parent | Merge pull request #265 from trilene/voip (diff) | |
download | nheko-5e344d2685c3efc191d53534cdcdc994c5d9463d.tar.xz |
Merge branch 'master' of https://github.com/Nheko-Reborn/nheko
39 files changed, 4088 insertions, 1218 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b82e285..d2dffccd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,7 +253,8 @@ set(SRC_FILES # Timeline - src/timeline/ReactionsModel.cpp + src/timeline/EventStore.cpp + src/timeline/Reaction.cpp src/timeline/TimelineViewManager.cpp src/timeline/TimelineModel.cpp src/timeline/DelegateChooser.cpp @@ -341,7 +342,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47 + GIT_TAG d8666a3f1a5b709b78ccea2b545d540a8cb502ca ) FetchContent_MakeAvailable(MatrixClient) else() @@ -465,7 +466,8 @@ qt5_wrap_cpp(MOC_HEADERS src/emoji/Provider.h # Timeline - src/timeline/ReactionsModel.h + src/timeline/EventStore.h + src/timeline/Reaction.h src/timeline/TimelineViewManager.h src/timeline/TimelineModel.h src/timeline/DelegateChooser.h diff --git a/README.md b/README.md index 20340a46..fb0167c8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ sudo eselect repository enable matrix sudo emerge -a nheko ``` +#### Nix(os) + +```bash +nix-env -iA nixpkgs.nheko +# or +nix-shell -p nheko --run nheko +``` + #### Alpine Linux (and postmarketOS) Make sure you have the testing repositories from `edge` enabled. Note that this is not needed on postmarketOS. diff --git a/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json index 9bebdb61..b11e587c 100644 --- a/io.github.NhekoReborn.Nheko.json +++ b/io.github.NhekoReborn.Nheko.json @@ -146,7 +146,7 @@ "name": "mtxclient", "sources": [ { - "commit": "744018c86a8094acbda9821d6d7b5a890d4aac47", + "commit": "d8666a3f1a5b709b78ccea2b545d540a8cb502ca", "type": "git", "url": "https://github.com/Nheko-Reborn/mtxclient.git" } diff --git a/resources/langs/nheko_en.ts b/resources/langs/nheko_en.ts index f2bb04f9..1e9128d0 100644 --- a/resources/langs/nheko_en.ts +++ b/resources/langs/nheko_en.ts @@ -198,7 +198,7 @@ <location filename="../qml/emoji/EmojiPicker.qml" line="+117"/> <location line="+139"/> <source>Search</source> - <translation type="unfinished"></translation> + <translation>Search</translation> </message> <message> <location line="-42"/> diff --git a/resources/langs/nheko_ro.ts b/resources/langs/nheko_ro.ts new file mode 100644 index 00000000..659c8f6c --- /dev/null +++ b/resources/langs/nheko_ro.ts @@ -0,0 +1,1815 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="ro"> +<context> + <name>Cache</name> + <message> + <location filename="../../src/Cache.cpp" line="+1359"/> + <source>You joined this room.</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>ChatPage</name> + <message> + <location filename="../../src/ChatPage.cpp" line="+229"/> + <source>Failed to invite user: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <location line="+926"/> + <source>Invited user: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-474"/> + <source>Migrating the cache to the current version failed. This can have different reasons. Please open an issue and try to use an older version in the mean time. Alternatively you can try deleting the cache manually.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+439"/> + <source>Room %1 created.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+30"/> + <source>Failed to invite %1 to %2: %3</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+19"/> + <source>Failed to kick %1 to %2: %3</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Kicked user: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+14"/> + <source>Failed to ban %1 in %2: %3</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Banned user: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+14"/> + <source>Failed to unban %1 in %2: %3</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Unbanned user: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-828"/> + <source>Failed to upload media. Please try again.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+296"/> + <source>Cache migration failed!</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+13"/> + <source>Incompatible cache version</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>The cache on your disk is newer than this version of Nheko supports. Please update or clear your cache.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+111"/> + <source>Failed to restore OLM account. Please login again.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Failed to restore save data. Please login again.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+156"/> + <source>Failed to setup encryption keys. Server response: %1 %2. Please try again later.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+54"/> + <location line="+252"/> + <source>Please try to login again: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-187"/> + <source>Failed to join room: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You joined the room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Failed to remove invite: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+19"/> + <source>Room creation failed: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+16"/> + <source>Failed to leave room: %1</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>CommunitiesListItem</name> + <message> + <location filename="../../src/CommunitiesListItem.cpp" line="+133"/> + <source>All rooms</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Favourite rooms</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Low priority rooms</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Server Notices</source> + <comment>Tag translation for m.server_notice</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <location line="+2"/> + <source> (tag)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source> (community)</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>EditModal</name> + <message> + <location filename="../../src/dialogs/RoomSettings.cpp" line="+72"/> + <source>Apply</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+10"/> + <source>Name</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Topic</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>EmojiPicker</name> + <message> + <location filename="../qml/emoji/EmojiPicker.qml" line="+117"/> + <location line="+139"/> + <source>Search</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-42"/> + <source>People</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Nature</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Food</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Activity</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Travel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Objects</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Symbols</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Flags</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>EncryptionIndicator</name> + <message> + <location filename="../qml/EncryptionIndicator.qml" line="+36"/> + <source>Encrypted</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>This message is not encrypted!</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>InviteeItem</name> + <message> + <location filename="../../src/InviteeItem.cpp" line="+18"/> + <source>Remove</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>LoginPage</name> + <message> + <location filename="../../src/LoginPage.cpp" line="+90"/> + <source>Matrix ID</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>e.g @joe:matrix.org</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Your login name. A mxid should start with @ followed by the user id. After the user id you need to include your server name after a :. +You can also put your homeserver address there, if your server doesn't support .well-known lookup. +Example: @user:server.my +If Nheko fails to discover your homeserver, it will show you a field to enter the server manually.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+21"/> + <source>Password</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Device name</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>A name for this device, which will be shown to others, when verifying your devices. If none is provided a default is used.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>The address that can be used to contact you homeservers client API. +Example: https://server.my:8787</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+16"/> + <location line="+191"/> + <source>LOGIN</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-100"/> + <source>Autodiscovery failed. Received malformed response.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Autodiscovery failed. Unknown error when requesting .well-known.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+25"/> + <source>The required endpoints were not found. Possibly not a Matrix server.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Received malformed response. Make sure the homeserver domain is valid.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>An unknown error occured. Make sure the homeserver domain is valid.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+55"/> + <source>SSO LOGIN</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+25"/> + <source>Empty password</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+52"/> + <source>SSO login failed</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>MemberList</name> + <message> + <location filename="../../src/dialogs/MemberList.cpp" line="+90"/> + <source>Room members</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>OK</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>MessageDelegate</name> + <message> + <location filename="../qml/delegates/MessageDelegate.qml" line="+66"/> + <source>redacted</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Encryption enabled</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>room name changed to: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+0"/> + <source>removed room name</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>topic changed to: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+0"/> + <source>removed topic</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>%1 created and configured room: %2</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>Placeholder</name> + <message> + <location filename="../qml/delegates/Placeholder.qml" line="+4"/> + <source>unimplemented event: </source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>QuickSwitcher</name> + <message> + <location filename="../../src/QuickSwitcher.cpp" line="+74"/> + <source>Search for a room...</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>RegisterPage</name> + <message> + <location filename="../../src/RegisterPage.cpp" line="+88"/> + <source>Username</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>The username must not be empty, and must contain only the characters a-z, 0-9, ., _, =, -, and /.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Password</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Please choose a secure password. The exact requirements for password strength may depend on your server.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Password confirmation</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Homeserver</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>A server that allows registration. Since matrix is decentralized, you need to first find a server you can register on or host your own.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+17"/> + <source>REGISTER</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+55"/> + <source>No supported registration flows!</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+135"/> + <source>Invalid username</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Password is not long enough (min 8 chars)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Passwords don't match</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Invalid server name</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>RoomInfo</name> + <message> + <location filename="../../src/Cache.cpp" line="+984"/> + <source>no version stored</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>RoomInfoListItem</name> + <message> + <location filename="../../src/RoomInfoListItem.cpp" line="+102"/> + <source>Leave room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>Tag room as:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+18"/> + <source>Favourite</source> + <comment>Standard matrix tag for favourites</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Low Priority</source> + <comment>Standard matrix tag for low priority rooms</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Server Notice</source> + <comment>Standard matrix tag for server notices</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+12"/> + <source>Adds or removes the specified tag.</source> + <comment>WhatsThis hint for tag menu actions</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+38"/> + <source>New tag...</source> + <comment>Add a new tag to the room</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>New Tag</source> + <comment>Tag name prompt title</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Tag:</source> + <comment>Tag name prompt</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+173"/> + <source>Accept</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Decline</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>SideBarActions</name> + <message> + <location filename="../../src/SideBarActions.cpp" line="+40"/> + <source>User settings</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>Create new room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Join a room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+16"/> + <source>Start a new chat</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+15"/> + <source>Room directory</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>StatusIndicator</name> + <message> + <location filename="../qml/StatusIndicator.qml" line="+14"/> + <source>Failed</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Sent</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Received</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Read</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TextInputWidget</name> + <message> + <location filename="../../src/TextInputWidget.cpp" line="+460"/> + <source>Send a file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+13"/> + <location filename="../../src/TextInputWidget.h" line="+145"/> + <source>Write a message...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+31"/> + <source>Send a message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Emoji</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+86"/> + <source>Select a file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+0"/> + <source>All Files (*)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="../../src/TextInputWidget.h" line="-5"/> + <source>Connection lost. Nheko is trying to re-connect...</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TimelineModel</name> + <message> + <location filename="../../src/timeline/TimelineModel.cpp" line="+891"/> + <source>-- Decryption Error (failed to communicate with DB) --</source> + <comment>Placeholder, when the message can't be decrypted, because the DB access failed when trying to lookup the session.</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+20"/> + <source>-- Decryption Error (failed to retrieve megolm keys from db) --</source> + <comment>Placeholder, when the message can't be decrypted, because the DB access failed.</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+13"/> + <source>-- Decryption Error (%1) --</source> + <comment>Placeholder, when the message can't be decrypted. In this case, the Olm decrytion returned an error, which is passed ad %1.</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+99"/> + <source>Message redaction failed: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+488"/> + <source>Save image</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Save video</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Save audio</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Save file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-643"/> + <source>-- Encrypted Event (No keys found for decryption) --</source> + <comment>Placeholder, when the message was not decrypted yet or can't be decrypted.</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+82"/> + <source>-- Encrypted Event (Unknown event type) --</source> + <comment>Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet.</comment> + <translation type="unfinished"></translation> + </message> + <message numerus="yes"> + <location line="+693"/> + <source>%1 and %2 are typing.</source> + <comment>Multiple users are typing. First argument is a comma separated list of potentially multiple users. Second argument is the last user of that list. (If only one user is typing, %1 is empty. You should still use it in your string though to silence Qt warnings.)</comment> + <translation type="unfinished"> + <numerusform></numerusform> + <numerusform></numerusform> + <numerusform></numerusform> + </translation> + </message> + <message> + <location line="+68"/> + <source>%1 opened the room to the public.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 made this room require and invitation to join.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+23"/> + <source>%1 made the room open to guests.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 has closed the room to guest access.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+23"/> + <source>%1 made the room history world readable. Events may be now read by non-joined people.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>%1 set the room history visible to members from this point on.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 set the room history visible to members since they were invited.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 set the room history visible to members since they joined the room.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+22"/> + <source>%1 has changed the room's permissions.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+48"/> + <source>%1 was invited.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+11"/> + <source>%1 changed their display name and avatar.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 changed their display name.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 changed their avatar.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 changed some profile info.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>%1 joined.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+9"/> + <source>%1 rejected their invite.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Revoked the invite to %1.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 left the room.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Kicked %1.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Unbanned %1.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+14"/> + <source>%1 was banned.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-11"/> + <source>%1 redacted their knock.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-1281"/> + <source>You joined this room.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1283"/> + <source>Rejected the knock from %1.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>%1 left after having already left!</source> + <comment>This is a leave event after the user already left and shouldn't happen apart from state resets</comment> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+15"/> + <source> Reason: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-5"/> + <source>%1 knocked.</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TimelineRow</name> + <message> + <location filename="../qml/TimelineRow.qml" line="+91"/> + <source>React</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+17"/> + <source>Reply</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+15"/> + <source>Options</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TimelineView</name> + <message> + <location filename="../qml/TimelineView.qml" line="+54"/> + <source>React</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Reply</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Read receipts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Mark as read</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>View raw message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>View decrypted raw message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Redact message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Save as</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+14"/> + <source>No room open</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+271"/> + <source>Close</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TopRoomBar</name> + <message> + <location filename="../../src/TopRoomBar.cpp" line="+86"/> + <source>Room options</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Mentions</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+34"/> + <source>Invite users</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Members</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Leave room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Settings</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>TrayIcon</name> + <message> + <location filename="../../src/TrayIcon.cpp" line="+122"/> + <source>Show</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Quit</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>UserInfoWidget</name> + <message> + <location filename="../../src/UserInfoWidget.cpp" line="+95"/> + <source>Logout</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+20"/> + <source>Set custom status message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Custom status message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Status:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+9"/> + <source>Set presence automatically</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Online</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Unavailable</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+6"/> + <source>Offline</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>UserSettingsPage</name> + <message> + <location filename="../../src/UserSettingsPage.cpp" line="+535"/> + <source>Minimize to tray</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Start in tray</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>Group's sidebar</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-3"/> + <source>Circular Avatars</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-5"/> + <source>Keep the application running in the background after closing the client window.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Start the application in the background without showing the client window.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Change the appearance of user avatars in chats. +OFF - square, ON - Circle.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Show a column containing groups and tags next to the room list.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Decrypt messages in sidebar</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Decrypt the messages shown in the sidebar. +Only affects messages in encrypted chats.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Show buttons in timeline</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Show buttons to quickly reply, react or access additional options next to each message.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Limit width of timeline</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Set the max width of messages in the timeline (in pixels). This can help readability on wide screen, when Nheko is maximised</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Typing notifications</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Show who is typing in a room. +This will also enable or disable sending typing notifications to others.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Sort rooms by unreads</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Display rooms with new messages first. +If this is off, the list of rooms will only be sorted by the timestamp of the last message in a room. +If this is on, rooms which have active notifications (the small circle with a number in it) will be sorted on top. Rooms, that you have muted, will still be sorted by timestamp, since you don't seem to consider them as important as the other rooms.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>Read receipts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Show if your message was read. +Status is displayed next to timestamps.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Send messages as Markdown</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Allow using markdown in messages. +When disabled, all messages are sent as a plain text.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Desktop notifications</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Notify about received message when the client is not currently focused.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Alert on notification</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Show an alert when a message is received. +This usually causes the application icon in the task bar to animate in some fashion.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Highlight message on hover</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Change the background color of messages when you hover over them.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Large Emoji in timeline</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Make font size larger if messages with only a few emojis are displayed.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Scale factor</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Change the scale factor of the whole user interface.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Font size</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Font Family</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Theme</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Device ID</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Device Fingerprint</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-113"/> + <source>Session Keys</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>IMPORT</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>EXPORT</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-25"/> + <source>ENCRYPTION</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-71"/> + <source>GENERAL</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+29"/> + <source>INTERFACE</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+166"/> + <source>Emoji Font Family</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+179"/> + <source>Open Sessions File</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <location line="+18"/> + <location line="+9"/> + <location line="+2"/> + <location line="+2"/> + <location line="+19"/> + <location line="+11"/> + <location line="+18"/> + <location line="+2"/> + <location line="+2"/> + <source>Error</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-74"/> + <location line="+32"/> + <source>File Password</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-31"/> + <source>Enter the passphrase to decrypt the file:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <location line="+32"/> + <source>The password cannot be empty</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="-8"/> + <source>Enter passphrase to encrypt your session keys:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+15"/> + <source>File to save the exported session keys</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>WelcomePage</name> + <message> + <location filename="../../src/WelcomePage.cpp" line="+47"/> + <source>Welcome to nheko! The desktop client for the Matrix protocol.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Enjoy your stay!</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+23"/> + <source>REGISTER</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>LOGIN</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>descriptiveTime</name> + <message> + <location filename="../../src/Utils.cpp" line="+147"/> + <source>Yesterday</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::CreateRoom</name> + <message> + <location filename="../../src/dialogs/CreateRoom.cpp" line="+36"/> + <source>Create room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+10"/> + <source>Name</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Topic</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Alias</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Room Visibility</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Room Preset</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+9"/> + <source>Direct Chat</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::FallbackAuth</name> + <message> + <location filename="../../src/dialogs/FallbackAuth.cpp" line="+30"/> + <source>Open Fallback in Browser</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Confirm</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+12"/> + <source>Open the fallback, follow the steps and confirm after completing them.</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::InviteUsers</name> + <message> + <location filename="../../src/dialogs/InviteUsers.cpp" line="+42"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>User ID to invite</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::JoinRoom</name> + <message> + <location filename="../../src/dialogs/JoinRoom.cpp" line="+30"/> + <source>Join</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>Room ID or alias</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::LeaveRoom</name> + <message> + <location filename="../../src/dialogs/LeaveRoom.cpp" line="+31"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Are you sure you want to leave?</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::Logout</name> + <message> + <location filename="../../src/dialogs/Logout.cpp" line="+47"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Logout. Are you sure?</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::PreviewUploadOverlay</name> + <message> + <location filename="../../src/dialogs/PreviewUploadOverlay.cpp" line="+41"/> + <source>Upload</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+84"/> + <source>Media type: %1 +Media size: %2 +</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::ReCaptcha</name> + <message> + <location filename="../../src/dialogs/ReCaptcha.cpp" line="+31"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Confirm</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+11"/> + <source>Solve the reCAPTCHA and press the confirm button</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::ReadReceipts</name> + <message> + <location filename="../../src/dialogs/ReadReceipts.cpp" line="+120"/> + <source>Read receipts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Close</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::ReceiptItem</name> + <message> + <location line="-46"/> + <source>Today %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Yesterday %1</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::RoomSettings</name> + <message> + <location filename="../../src/dialogs/RoomSettings.cpp" line="+135"/> + <source>Settings</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Info</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+11"/> + <source>Internal ID</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+10"/> + <source>Room Version</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Notifications</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Muted</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Mentions only</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>All messages</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+97"/> + <source>Room access</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+2"/> + <source>Anyone and guests</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Anyone</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Invited users</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+50"/> + <source>Encryption</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>End-to-End Encryption</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>Encryption is currently experimental and things might break unexpectedly. <br>Please take note that it can't be disabled afterwards.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+27"/> + <source>Respond to key requests</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Whether or not the client should respond automatically with the session keys + upon request. Use with caution, this is a temporary measure to test the + E2E implementation until device verification is completed.</source> + <translation type="unfinished"></translation> + </message> + <message numerus="yes"> + <location line="+51"/> + <source>%n member(s)</source> + <translation type="unfinished"> + <numerusform></numerusform> + <numerusform></numerusform> + <numerusform></numerusform> + </translation> + </message> + <message> + <location line="+140"/> + <source>Failed to enable encryption: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+147"/> + <source>Select an avatar</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+0"/> + <source>All Files (*)</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+12"/> + <source>The selected file is not an image</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>Error while reading file: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+35"/> + <location line="+20"/> + <source>Failed to upload image: %s</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>dialogs::UserProfile</name> + <message> + <location filename="../../src/dialogs/UserProfile.cpp" line="+63"/> + <source>Ban the user from the room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Ignore messages from this user</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+9"/> + <source>Kick the user from the room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+8"/> + <source>Start a conversation</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+61"/> + <source>Devices</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>emoji::Panel</name> + <message> + <location filename="../../src/emoji/Panel.cpp" line="+122"/> + <source>Smileys & People</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Animals & Nature</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Food & Drink</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Activity</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+4"/> + <source>Travel & Places</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Objects</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Symbols</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>Flags</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>message-description sent:</name> + <message> + <location filename="../../src/Utils.h" line="+103"/> + <source>You sent an audio clip</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent an audio clip</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You sent an image</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent an image</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You sent a file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent a file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You sent a video</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent a video</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You sent a sticker</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent a sticker</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You sent a notification</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent a notification</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+5"/> + <source>You: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1: %2</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+7"/> + <source>You sent an encrypted message</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+3"/> + <source>%1 sent an encrypted message</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>popups::UserMentions</name> + <message> + <location filename="../../src/popups/UserMentions.cpp" line="+64"/> + <source>This Room</source> + <translation type="unfinished"></translation> + </message> + <message> + <location line="+1"/> + <source>All Rooms</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>utils</name> + <message> + <location filename="../../src/Utils.h" line="+4"/> + <source>Unknown Message Type</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml index d56143dd..1da223d5 100644 --- a/resources/qml/MatrixText.qml +++ b/resources/qml/MatrixText.qml @@ -6,6 +6,7 @@ TextEdit { readOnly: true wrapMode: Text.Wrap selectByMouse: true + activeFocusOnPress: false color: colors.text onLinkActivated: { diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml index c06dc826..c1091756 100644 --- a/resources/qml/Reactions.qml +++ b/resources/qml/Reactions.qml @@ -30,11 +30,11 @@ Flow { implicitHeight: contentItem.childrenRect.height ToolTip.visible: hovered - ToolTip.text: model.users + ToolTip.text: modelData.users onClicked: { - console.debug("Picked " + model.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + model.selfReactedEvent) - timelineManager.reactToMessage(reactionFlow.roomId, reactionFlow.eventId, model.key, model.selfReactedEvent) + console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + " in room " + reactionFlow.roomId + ". selfReactedEvent: " + modelData.selfReactedEvent) + timelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key) } @@ -49,13 +49,13 @@ Flow { font.family: settings.emojiFont elide: Text.ElideRight elideWidth: 150 - text: model.key + text: modelData.key } Text { anchors.baseline: reactionCounter.baseline id: reactionText - text: textMetrics.elidedText + (textMetrics.elidedText == model.key ? "" : "…") + text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…") font.family: settings.emojiFont color: reaction.hovered ? colors.highlight : colors.text maximumLineCount: 1 @@ -65,13 +65,13 @@ Flow { id: divider height: Math.floor(reactionCounter.implicitHeight * 1.4) width: 1 - color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text + color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text } Text { anchors.verticalCenter: divider.verticalCenter id: reactionCounter - text: model.counter + text: modelData.count font: reaction.font color: reaction.hovered ? colors.highlight : colors.text } @@ -82,8 +82,8 @@ Flow { implicitWidth: reaction.implicitWidth implicitHeight: reaction.implicitHeight - border.color: (reaction.hovered || model.selfReactedEvent !== '') ? colors.highlight : colors.text - color: model.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base + border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text + color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.20) : colors.base border.width: 1 radius: reaction.height / 2.0 } diff --git a/resources/qml/ScrollHelper.qml b/resources/qml/ScrollHelper.qml index e72fcbd2..ba7c2648 100644 --- a/resources/qml/ScrollHelper.qml +++ b/resources/qml/ScrollHelper.qml @@ -106,6 +106,6 @@ MouseArea { //How long the scrollbar will remain visible interval: 500 // Hide the scrollbars - onTriggered: flickable.cancelFlick(); + onTriggered: { flickable.cancelFlick(); flickable.movementEnded(); } } } diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml index dfee62dc..a38a4d34 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml @@ -8,22 +8,25 @@ import im.nheko 1.0 import "./delegates" import "./emoji" -MouseArea { +Item { anchors.left: parent.left anchors.right: parent.right height: row.height - propagateComposedEvents: true - preventStealing: true - hoverEnabled: true - - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: { - if (mouse.button === Qt.RightButton) - messageContextMenu.show(model.id, model.type, model.isEncrypted, row) - } - onPressAndHold: { - if (mouse.source === Qt.MouseEventNotSynthesized) - messageContextMenu.show(model.id, model.type, model.isEncrypted, row) + + MouseArea { + anchors.fill: parent + propagateComposedEvents: true + preventStealing: true + hoverEnabled: true + + acceptedButtons: Qt.AllButtons + onClicked: { + if (mouse.button === Qt.RightButton) + messageContextMenu.show(model.id, model.type, model.isEncrypted, row) + } + onPressAndHold: { + messageContextMenu.show(model.id, model.type, model.isEncrypted, row, mapToItem(timelineRoot, mouse.x, mouse.y)) + } } Rectangle { color: (settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent" @@ -45,7 +48,7 @@ MouseArea { // fancy reply, if this is a reply Reply { visible: model.replyTo - modelData: chat.model.getDump(model.replyTo) + modelData: chat.model.getDump(model.replyTo, model.id) userColor: timelineManager.userColor(modelData.userId, colors.window) } @@ -90,7 +93,6 @@ MouseArea { ToolTip.visible: hovered ToolTip.text: qsTr("React") emojiPicker: emojiPopup - room_id: model.roomId event_id: model.id } ImageButton { @@ -128,6 +130,7 @@ MouseArea { Label { Layout.alignment: Qt.AlignRight | Qt.AlignTop text: model.timestamp.toLocaleTimeString("HH:mm") + width: Math.max(implicitWidth, text.length*fontMetrics.maximumCharacterWidth) color: inactiveColors.text MouseArea{ diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 6af0372b..dd9c4029 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -11,6 +11,8 @@ import "./delegates" import "./emoji" Page { + id: timelineRoot + property var colors: currentActivePalette property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive @@ -25,34 +27,39 @@ Page { id: fontMetrics } - EmojiPicker { - id: emojiPopup - width: 7 * 52 + 20 - height: 6 * 52 - colors: palette - model: EmojiProxyModel { - category: EmojiCategory.People - sourceModel: EmojiModel {} - } - } + EmojiPicker { + id: emojiPopup + width: 7 * 52 + 20 + height: 6 * 52 + colors: palette + model: EmojiProxyModel { + category: EmojiCategory.People + sourceModel: EmojiModel {} + } + } Menu { id: messageContextMenu modal: true - function show(eventId_, eventType_, isEncrypted_, showAt) { + function show(eventId_, eventType_, isEncrypted_, showAt_, position) { eventId = eventId_ eventType = eventType_ isEncrypted = isEncrypted_ - popup(showAt) + + if (position) + popup(position, showAt_) + else + popup(showAt_) } property string eventId property int eventType property bool isEncrypted + MenuItem { text: qsTr("React") - onClicked: chat.model.reactAction(messageContextMenu.eventId) + onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId) } MenuItem { text: qsTr("Reply") @@ -87,8 +94,6 @@ Page { } } - id: timelineRoot - Rectangle { anchors.fill: parent color: colors.window @@ -113,7 +118,7 @@ Page { ListView { id: chat - visible: timelineManager.timeline != null + visible: !!timelineManager.timeline cacheBuffer: 400 @@ -181,7 +186,7 @@ Page { id: wrapper property Item section - anchors.horizontalCenter: parent.horizontalCenter + anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined width: chat.delegateMaxWidth height: section ? section.height + timelinerow.height : timelinerow.height color: "transparent" @@ -205,14 +210,13 @@ Page { } } - Binding { - target: chat.model - property: "currentIndex" - when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height - value: index - delayed: true + Connections { + target: chat + function onMovementEnded() { + if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) + chat.model.currentIndex = index; + } } - } section { @@ -296,13 +300,13 @@ Page { } } - footer: BusyIndicator { - anchors.horizontalCenter: parent.horizontalCenter - running: chat.model && chat.model.paginationInProgress - height: 50 - width: 50 - z: 3 - } + footer: BusyIndicator { + anchors.horizontalCenter: parent.horizontalCenter + running: chat.model && chat.model.paginationInProgress + height: 50 + width: 50 + z: 3 + } } Rectangle { @@ -354,7 +358,7 @@ Page { anchors.rightMargin: 20 anchors.bottom: parent.bottom - modelData: chat.model ? chat.model.getDump(chat.model.reply) : {} + modelData: chat.model ? chat.model.getDump(chat.model.reply, chat.model.id) : {} userColor: timelineManager.userColor(modelData.userId, colors.window) } diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml index 62d9de60..3885ddae 100644 --- a/resources/qml/delegates/ImageMessage.qml +++ b/resources/qml/delegates/ImageMessage.qml @@ -9,8 +9,8 @@ Item { property double divisor: model.isReply ? 4 : 2 property bool tooHigh: tempHeight > timelineRoot.height / divisor - height: tooHigh ? timelineRoot.height / divisor : tempHeight - width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth + height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight) + width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth) Image { id: blurhash diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml index ed18b2e5..56b8040e 100644 --- a/resources/qml/delegates/MessageDelegate.qml +++ b/resources/qml/delegates/MessageDelegate.qml @@ -67,6 +67,12 @@ Item { } } DelegateChoice { + roleValue: MtxEvent.Redaction + Pill { + text: qsTr("redacted") + } + } + DelegateChoice { roleValue: MtxEvent.Encryption Pill { text: qsTr("Encryption enabled") @@ -109,6 +115,12 @@ Item { } } DelegateChoice { + roleValue: MtxEvent.CallCandidates + NoticeMessage { + text: qsTr("Negotiating call...") + } + } + DelegateChoice { // TODO: make a more complex formatter for the power levels. roleValue: MtxEvent.PowerLevels NoticeMessage { diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml index bab524eb..8d2fa8a8 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -9,7 +9,7 @@ Rectangle { id: bg radius: 10 color: colors.dark - height: content.height + 24 + height: Math.round(content.height + 24) width: parent ? parent.width : undefined Column { diff --git a/resources/qml/delegates/TextMessage.qml b/resources/qml/delegates/TextMessage.qml index b3c45c36..cc2d2da0 100644 --- a/resources/qml/delegates/TextMessage.qml +++ b/resources/qml/delegates/TextMessage.qml @@ -4,7 +4,7 @@ MatrixText { property string formatted: model.data.formattedBody text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>") width: parent ? parent.width : undefined - height: isReply ? Math.min(chat.height / 8, implicitHeight) : undefined + height: isReply ? Math.round(Math.min(timelineRoot.height / 8, implicitHeight)) : undefined clip: true font.pointSize: (settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? settings.fontSize * 3 : settings.fontSize } diff --git a/resources/qml/emoji/EmojiButton.qml b/resources/qml/emoji/EmojiButton.qml index f8f75e3e..c5eee4e4 100644 --- a/resources/qml/emoji/EmojiButton.qml +++ b/resources/qml/emoji/EmojiButton.qml @@ -8,11 +8,10 @@ import "../" ImageButton { property var colors: currentActivePalette property var emojiPicker - property string room_id property string event_id image: ":/icons/icons/ui/smile.png" id: emojiButton - onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, room_id, event_id) + onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id) } diff --git a/resources/qml/emoji/EmojiPicker.qml b/resources/qml/emoji/EmojiPicker.qml index ac67af2a..f75221d5 100644 --- a/resources/qml/emoji/EmojiPicker.qml +++ b/resources/qml/emoji/EmojiPicker.qml @@ -10,17 +10,17 @@ import "../" Popup { - function show(showAt, room_id, event_id) { - console.debug("Showing emojiPicker for " + event_id + "in room " + room_id) - parent = showAt - x = Math.round((showAt.width - width) / 2) - y = showAt.height - emojiPopup.room_id = room_id - emojiPopup.event_id = event_id - open() - } + function show(showAt, event_id) { + console.debug("Showing emojiPicker for " + event_id) + if (showAt){ + parent = showAt + x = Math.round((showAt.width - width) / 2) + y = showAt.height + } + emojiPopup.event_id = event_id + open() + } - property string room_id property string event_id property var colors property alias model: gridView.model @@ -102,9 +102,9 @@ Popup { } // TODO: maybe add favorites at some point? onClicked: { - console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id + " in room " + emojiPopup.room_id) + console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id) emojiPopup.close() - timelineManager.queueReactionMessage(emojiPopup.room_id, emojiPopup.event_id, model.unicode) + timelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode) } } diff --git a/src/Cache.cpp b/src/Cache.cpp index d435dc56..91cde9e7 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -33,11 +33,12 @@ #include "Cache_p.h" #include "EventAccessors.h" #include "Logging.h" +#include "Olm.h" #include "Utils.h" //! 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("2020.05.01"); +static const std::string CURRENT_CACHE_FORMAT_VERSION("2020.07.05"); static const std::string SECRET("secret"); static lmdb::val NEXT_BATCH_KEY("next_batch"); @@ -46,8 +47,9 @@ static lmdb::val CACHE_FORMAT_VERSION_KEY("cache_format_version"); constexpr size_t MAX_RESTORED_MESSAGES = 30'000; -constexpr auto DB_SIZE = 32ULL * 1024ULL * 1024ULL * 1024ULL; // 32 GB -constexpr auto MAX_DBS = 8092UL; +constexpr auto DB_SIZE = 32ULL * 1024ULL * 1024ULL * 1024ULL; // 32 GB +constexpr auto MAX_DBS = 8092UL; +constexpr auto BATCH_SIZE = 100; //! Cache databases and their format. //! @@ -63,7 +65,6 @@ constexpr auto SYNC_STATE_DB("sync_state"); //! Read receipts per room/event. constexpr auto READ_RECEIPTS_DB("read_receipts"); constexpr auto NOTIFICATIONS_DB("sent_notifications"); -//! TODO: delete pending_receipts database on old cache versions //! Encryption related databases. @@ -93,18 +94,31 @@ namespace { std::unique_ptr<Cache> instance_ = nullptr; } -int -numeric_key_comparison(const MDB_val *a, const MDB_val *b) +static bool +isHiddenEvent(mtx::events::collections::TimelineEvents e, const std::string &room_id) { - auto lhs = std::stoull(std::string((char *)a->mv_data, a->mv_size)); - auto rhs = std::stoull(std::string((char *)b->mv_data, b->mv_size)); + using namespace mtx::events; + if (auto encryptedEvent = std::get_if<EncryptedEvent<msg::Encrypted>>(&e)) { + MegolmSessionIndex index; + index.room_id = room_id; + index.session_id = encryptedEvent->content.session_id; + index.sender_key = encryptedEvent->content.sender_key; - if (lhs < rhs) - return 1; - else if (lhs == rhs) - return 0; + auto result = olm::decryptEvent(index, *encryptedEvent); + if (!result.error) + e = result.event.value(); + } - return -1; + static constexpr std::initializer_list<EventType> hiddenEvents = { + EventType::Reaction, EventType::CallCandidates, EventType::Unsupported}; + + return std::visit( + [](const auto &ev) { + return std::any_of(hiddenEvents.begin(), + hiddenEvents.end(), + [ev](EventType type) { return type == ev.type; }); + }, + e); } Cache::Cache(const QString &userId, QObject *parent) @@ -154,7 +168,10 @@ Cache::setup() } try { - env_.open(statePath.toStdString().c_str()); + // NOTE(Nico): We may want to use (MDB_MAPASYNC | MDB_WRITEMAP) in the future, but + // it can really mess up our database, so we shouldn't. For now, hopefully + // NOMETASYNC is fast enough. + env_.open(statePath.toStdString().c_str(), MDB_NOMETASYNC); } catch (const lmdb::error &e) { if (e.code() != MDB_VERSION_MISMATCH && e.code() != MDB_INVALID) { throw std::runtime_error("LMDB initialization failed" + @@ -700,8 +717,77 @@ Cache::runMigrations() nhlog::db()->info("Successfully deleted pending receipts database."); return true; }}, + {"2020.07.05", + [this]() { + try { + auto txn = lmdb::txn::begin(env_, nullptr); + auto room_ids = getRoomIds(txn); + + for (const auto &room_id : room_ids) { + try { + auto messagesDb = lmdb::dbi::open( + txn, std::string(room_id + "/messages").c_str()); + + // keep some old messages and batch token + { + auto roomsCursor = + lmdb::cursor::open(txn, messagesDb); + lmdb::val ts, stored_message; + bool start = true; + mtx::responses::Timeline oldMessages; + while (roomsCursor.get(ts, + stored_message, + start ? MDB_FIRST + : MDB_NEXT)) { + start = false; + + auto j = json::parse(std::string_view( + stored_message.data(), + stored_message.size())); + + if (oldMessages.prev_batch.empty()) + oldMessages.prev_batch = + j["token"].get<std::string>(); + else if (j["token"] != + oldMessages.prev_batch) + break; + + mtx::events::collections::TimelineEvent + te; + mtx::events::collections::from_json( + j["event"], te); + oldMessages.events.push_back(te.data); + } + // messages were stored in reverse order, so we + // need to reverse them + std::reverse(oldMessages.events.begin(), + oldMessages.events.end()); + // save messages using the new method + saveTimelineMessages(txn, room_id, oldMessages); + } + + // delete old messages db + lmdb::dbi_drop(txn, messagesDb, true); + } catch (std::exception &e) { + nhlog::db()->error( + "While migrating messages from {}, ignoring error {}", + room_id, + e.what()); + } + } + txn.commit(); + } catch (const lmdb::error &) { + nhlog::db()->critical( + "Failed to delete messages database in migration!"); + return false; + } + + nhlog::db()->info("Successfully deleted pending receipts database."); + return true; + }}, }; + nhlog::db()->info("Running migrations, this may take a while!"); for (const auto &[target_version, migration] : migrations) { if (target_version > stored_version) if (!migration()) { @@ -709,6 +795,7 @@ Cache::runMigrations() return false; } } + nhlog::db()->info("Migrations finished."); setCurrentFormat(); return true; @@ -771,8 +858,9 @@ Cache::readReceipts(const QString &event_id, const QString &room_id) txn.commit(); if (res) { - auto json_response = json::parse(std::string(value.data(), value.size())); - auto values = json_response.get<std::map<std::string, uint64_t>>(); + auto json_response = + json::parse(std::string_view(value.data(), value.size())); + auto values = json_response.get<std::map<std::string, uint64_t>>(); for (const auto &v : values) // timestamp, user_id @@ -810,8 +898,8 @@ Cache::updateReadReceipt(lmdb::txn &txn, const std::string &room_id, const Recei // If an entry for the event id already exists, we would // merge the existing receipts with the new ones. if (exists) { - auto json_value = - json::parse(std::string(prev_value.data(), prev_value.size())); + auto json_value = json::parse( + std::string_view(prev_value.data(), prev_value.size())); // Retrieve the saved receipts. saved_receipts = json_value.get<std::map<std::string, uint64_t>>(); @@ -930,7 +1018,7 @@ Cache::saveState(const mtx::responses::Sync &res) if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room.first), data)) { try { RoomInfo tmp = - json::parse(std::string(data.data(), data.size())); + json::parse(std::string_view(data.data(), data.size())); updatedInfo.tags = tmp.tags; } catch (const json::exception &e) { nhlog::db()->warn( @@ -1122,7 +1210,7 @@ Cache::singleRoomInfo(const std::string &room_id) // Check if the room is joined. if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room_id), data)) { try { - RoomInfo tmp = json::parse(std::string(data.data(), data.size())); + RoomInfo tmp = json::parse(std::string_view(data.data(), data.size())); tmp.member_count = getMembersDb(txn, room_id).size(txn); tmp.join_rule = getRoomJoinRule(txn, statesdb); tmp.guest_access = getRoomGuestAccess(txn, statesdb); @@ -1157,7 +1245,8 @@ Cache::getRoomInfo(const std::vector<std::string> &rooms) // Check if the room is joined. if (lmdb::dbi_get(txn, roomsDb_, lmdb::val(room), data)) { try { - RoomInfo tmp = json::parse(std::string(data.data(), data.size())); + RoomInfo tmp = + json::parse(std::string_view(data.data(), data.size())); tmp.member_count = getMembersDb(txn, room).size(txn); tmp.join_rule = getRoomJoinRule(txn, statesdb); tmp.guest_access = getRoomGuestAccess(txn, statesdb); @@ -1173,7 +1262,7 @@ Cache::getRoomInfo(const std::vector<std::string> &rooms) if (lmdb::dbi_get(txn, invitesDb_, lmdb::val(room), data)) { try { RoomInfo tmp = - json::parse(std::string(data.data(), data.size())); + json::parse(std::string_view(data.data(), data.size())); tmp.member_count = getInviteMembersDb(txn, room).size(txn); room_info.emplace(QString::fromStdString(room), @@ -1232,38 +1321,147 @@ Cache::getTimelineMentions() return notifs; } -mtx::responses::Timeline -Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id) +std::string +Cache::previousBatchToken(const std::string &room_id) +{ + auto txn = lmdb::txn::begin(env_, nullptr); + auto orderDb = getEventOrderDb(txn, room_id); + + auto cursor = lmdb::cursor::open(txn, orderDb); + lmdb::val indexVal, val; + if (!cursor.get(indexVal, val, MDB_FIRST)) { + return ""; + } + + auto j = json::parse(std::string_view(val.data(), val.size())); + + return j.value("prev_batch", ""); +} + +Cache::Messages +Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t index, bool forward) { // TODO(nico): Limit the messages returned by this maybe? - auto db = getMessagesDb(txn, room_id); + auto orderDb = getOrderToMessageDb(txn, room_id); + auto eventsDb = getEventsDb(txn, room_id); - mtx::responses::Timeline timeline; - std::string timestamp, msg; + Messages messages{}; - auto cursor = lmdb::cursor::open(txn, db); + lmdb::val indexVal, event_id; - size_t index = 0; + auto cursor = lmdb::cursor::open(txn, orderDb); + if (index == std::numeric_limits<uint64_t>::max()) { + if (cursor.get(indexVal, event_id, forward ? MDB_FIRST : MDB_LAST)) { + index = *indexVal.data<uint64_t>(); + } else { + messages.end_of_cache = true; + return messages; + } + } else { + if (cursor.get(indexVal, event_id, MDB_SET)) { + index = *indexVal.data<uint64_t>(); + } else { + messages.end_of_cache = true; + return messages; + } + } - while (cursor.get(timestamp, msg, MDB_NEXT) && index < MAX_RESTORED_MESSAGES) { - auto obj = json::parse(msg); + int counter = 0; - if (obj.count("event") == 0 || obj.count("token") == 0) + bool ret; + while ((ret = cursor.get(indexVal, + event_id, + counter == 0 ? (forward ? MDB_FIRST : MDB_LAST) + : (forward ? MDB_NEXT : MDB_PREV))) && + counter++ < BATCH_SIZE) { + lmdb::val event; + bool success = lmdb::dbi_get(txn, eventsDb, event_id, event); + if (!success) continue; - mtx::events::collections::TimelineEvent event; - mtx::events::collections::from_json(obj.at("event"), event); - - index += 1; + mtx::events::collections::TimelineEvent te; + try { + mtx::events::collections::from_json( + json::parse(std::string_view(event.data(), event.size())), te); + } catch (std::exception &e) { + nhlog::db()->error("Failed to parse message from cache {}", e.what()); + continue; + } - timeline.events.push_back(event.data); - timeline.prev_batch = obj.at("token").get<std::string>(); + messages.timeline.events.push_back(std::move(te.data)); } cursor.close(); - std::reverse(timeline.events.begin(), timeline.events.end()); + // std::reverse(timeline.events.begin(), timeline.events.end()); + messages.next_index = *indexVal.data<uint64_t>(); + messages.end_of_cache = !ret; - return timeline; + return messages; +} + +std::optional<mtx::events::collections::TimelineEvent> +Cache::getEvent(const std::string &room_id, const std::string &event_id) +{ + auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY); + auto eventsDb = getEventsDb(txn, room_id); + + lmdb::val event{}; + bool success = lmdb::dbi_get(txn, eventsDb, lmdb::val(event_id), event); + if (!success) + return {}; + + mtx::events::collections::TimelineEvent te; + try { + mtx::events::collections::from_json( + json::parse(std::string_view(event.data(), event.size())), te); + } catch (std::exception &e) { + nhlog::db()->error("Failed to parse message from cache {}", e.what()); + return std::nullopt; + } + + return te; +} +void +Cache::storeEvent(const std::string &room_id, + const std::string &event_id, + const mtx::events::collections::TimelineEvent &event) +{ + auto txn = lmdb::txn::begin(env_); + auto eventsDb = getEventsDb(txn, room_id); + auto event_json = mtx::accessors::serialize_event(event.data); + lmdb::dbi_put(txn, eventsDb, lmdb::val(event_id), lmdb::val(event_json.dump())); + txn.commit(); +} + +std::vector<std::string> +Cache::relatedEvents(const std::string &room_id, const std::string &event_id) +{ + auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY); + auto relationsDb = getRelationsDb(txn, room_id); + + std::vector<std::string> related_ids; + + auto related_cursor = lmdb::cursor::open(txn, relationsDb); + lmdb::val related_to = event_id, related_event; + bool first = true; + + try { + if (!related_cursor.get(related_to, related_event, MDB_SET)) + return {}; + + while (related_cursor.get( + related_to, related_event, first ? MDB_FIRST_DUP : MDB_NEXT_DUP)) { + first = false; + if (event_id != std::string_view(related_to.data(), related_to.size())) + break; + + related_ids.emplace_back(related_event.data(), related_event.size()); + } + } catch (const lmdb::error &e) { + nhlog::db()->error("related events error: {}", e.what()); + } + + return related_ids; } QMap<QString, RoomInfo> @@ -1306,55 +1504,113 @@ Cache::roomInfo(bool withInvites) std::string Cache::getLastEventId(lmdb::txn &txn, const std::string &room_id) { - auto db = getMessagesDb(txn, room_id); + auto orderDb = getOrderToMessageDb(txn, room_id); + + lmdb::val indexVal, val; + + auto cursor = lmdb::cursor::open(txn, orderDb); + if (!cursor.get(indexVal, val, MDB_LAST)) { + return {}; + } + + return std::string(val.data(), val.size()); +} + +std::optional<Cache::TimelineRange> +Cache::getTimelineRange(const std::string &room_id) +{ + auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY); + lmdb::dbi orderDb{0}; + try { + orderDb = getOrderToMessageDb(txn, room_id); + } catch (lmdb::runtime_error &e) { + nhlog::db()->error("Can't open db for room '{}', probably doesn't exist yet. ({})", + room_id, + e.what()); + return {}; + } - if (db.size(txn) == 0) + lmdb::val indexVal, val; + + auto cursor = lmdb::cursor::open(txn, orderDb); + if (!cursor.get(indexVal, val, MDB_LAST)) { return {}; + } - std::string timestamp, msg; + TimelineRange range{}; + range.last = *indexVal.data<uint64_t>(); - auto cursor = lmdb::cursor::open(txn, db); - while (cursor.get(timestamp, msg, MDB_NEXT)) { - auto obj = json::parse(msg); + if (!cursor.get(indexVal, val, MDB_FIRST)) { + return {}; + } + range.first = *indexVal.data<uint64_t>(); - if (obj.count("event") == 0) - continue; + return range; +} +std::optional<uint64_t> +Cache::getTimelineIndex(const std::string &room_id, std::string_view event_id) +{ + auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY); + auto orderDb = getMessageToOrderDb(txn, room_id); - cursor.close(); - return obj["event"]["event_id"]; + lmdb::val indexVal{event_id.data(), event_id.size()}, val; + + bool success = lmdb::dbi_get(txn, orderDb, indexVal, val); + if (!success) { + return {}; } - cursor.close(); - return {}; + return *val.data<uint64_t>(); +} + +std::optional<std::string> +Cache::getTimelineEventId(const std::string &room_id, uint64_t index) +{ + auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY); + auto orderDb = getOrderToMessageDb(txn, room_id); + + lmdb::val indexVal{&index, sizeof(index)}, val; + + bool success = lmdb::dbi_get(txn, orderDb, indexVal, val); + if (!success) { + return {}; + } + + return std::string(val.data(), val.size()); } DescInfo Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id) { - auto db = getMessagesDb(txn, room_id); - - if (db.size(txn) == 0) + auto orderDb = getOrderToMessageDb(txn, room_id); + auto eventsDb = getEventsDb(txn, room_id); + if (orderDb.size(txn) == 0) return DescInfo{}; - std::string timestamp, msg; - const auto local_user = utils::localUser(); DescInfo fallbackDesc{}; - auto cursor = lmdb::cursor::open(txn, db); - while (cursor.get(timestamp, msg, MDB_NEXT)) { - auto obj = json::parse(msg); + lmdb::val indexVal, event_id; - if (obj.count("event") == 0) + auto cursor = lmdb::cursor::open(txn, orderDb); + bool first = true; + while (cursor.get(indexVal, event_id, first ? MDB_LAST : MDB_PREV)) { + first = false; + + lmdb::val event; + bool success = lmdb::dbi_get(txn, eventsDb, event_id, event); + if (!success) continue; - if (fallbackDesc.event_id.isEmpty() && obj["event"]["type"] == "m.room.member" && - obj["event"]["state_key"] == local_user.toStdString() && - obj["event"]["content"]["membership"] == "join") { - uint64_t ts = obj["event"]["origin_server_ts"]; + auto obj = json::parse(std::string_view(event.data(), event.size())); + + if (fallbackDesc.event_id.isEmpty() && obj["type"] == "m.room.member" && + obj["state_key"] == local_user.toStdString() && + obj["content"]["membership"] == "join") { + uint64_t ts = obj["origin_server_ts"]; auto time = QDateTime::fromMSecsSinceEpoch(ts); - fallbackDesc = DescInfo{QString::fromStdString(obj["event"]["event_id"]), + fallbackDesc = DescInfo{QString::fromStdString(obj["event_id"]), local_user, tr("You joined this room."), utils::descriptiveTime(time), @@ -1362,20 +1618,17 @@ Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id) time}; } - if (!(obj["event"]["type"] == "m.room.message" || - obj["event"]["type"] == "m.sticker" || - obj["event"]["type"] == "m.call.invite" || - obj["event"]["type"] == "m.call.answer" || - obj["event"]["type"] == "m.call.hangup" || - obj["event"]["type"] == "m.room.encrypted")) + if (!(obj["type"] == "m.room.message" || obj["type"] == "m.sticker" || + obj["type"] == "m.call.invite" || obj["type"] == "m.call.answer" || + obj["type"] == "m.call.hangup" || obj["type"] == "m.room.encrypted")) continue; - mtx::events::collections::TimelineEvent event; - mtx::events::collections::from_json(obj.at("event"), event); + mtx::events::collections::TimelineEvent te; + mtx::events::collections::from_json(obj, te); cursor.close(); return utils::getMessageDescription( - event.data, local_user, QString::fromStdString(room_id)); + te.data, local_user, QString::fromStdString(room_id)); } cursor.close(); @@ -1417,7 +1670,7 @@ Cache::getRoomAvatarUrl(lmdb::txn &txn, if (res) { try { StateEvent<Avatar> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); if (!msg.content.url.empty()) return QString::fromStdString(msg.content.url); @@ -1467,7 +1720,8 @@ Cache::getRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &membersdb) if (res) { try { - StateEvent<Name> msg = json::parse(std::string(event.data(), event.size())); + StateEvent<Name> msg = + json::parse(std::string_view(event.data(), event.size())); if (!msg.content.name.empty()) return QString::fromStdString(msg.content.name); @@ -1482,7 +1736,7 @@ Cache::getRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &membersdb) if (res) { try { StateEvent<CanonicalAlias> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); if (!msg.content.alias.empty()) return QString::fromStdString(msg.content.alias); @@ -1545,7 +1799,7 @@ Cache::getRoomJoinRule(lmdb::txn &txn, lmdb::dbi &statesdb) if (res) { try { StateEvent<state::JoinRules> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); return msg.content.join_rule; } catch (const json::exception &e) { nhlog::db()->warn("failed to parse m.room.join_rule event: {}", e.what()); @@ -1567,7 +1821,7 @@ Cache::getRoomGuestAccess(lmdb::txn &txn, lmdb::dbi &statesdb) if (res) { try { StateEvent<GuestAccess> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); return msg.content.guest_access == AccessState::CanJoin; } catch (const json::exception &e) { nhlog::db()->warn("failed to parse m.room.guest_access event: {}", @@ -1590,7 +1844,7 @@ Cache::getRoomTopic(lmdb::txn &txn, lmdb::dbi &statesdb) if (res) { try { StateEvent<Topic> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); if (!msg.content.topic.empty()) return QString::fromStdString(msg.content.topic); @@ -1615,7 +1869,7 @@ Cache::getRoomVersion(lmdb::txn &txn, lmdb::dbi &statesdb) if (res) { try { StateEvent<Create> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); if (!msg.content.room_version.empty()) return QString::fromStdString(msg.content.room_version); @@ -1641,7 +1895,7 @@ Cache::getInviteRoomName(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &members if (res) { try { StrippedEvent<state::Name> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); return QString::fromStdString(msg.content.name); } catch (const json::exception &e) { nhlog::db()->warn("failed to parse m.room.name event: {}", e.what()); @@ -1683,7 +1937,7 @@ Cache::getInviteRoomAvatarUrl(lmdb::txn &txn, lmdb::dbi &statesdb, lmdb::dbi &me if (res) { try { StrippedEvent<state::Avatar> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); return QString::fromStdString(msg.content.url); } catch (const json::exception &e) { nhlog::db()->warn("failed to parse m.room.avatar event: {}", e.what()); @@ -1725,7 +1979,7 @@ Cache::getInviteRoomTopic(lmdb::txn &txn, lmdb::dbi &db) if (res) { try { StrippedEvent<Topic> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); return QString::fromStdString(msg.content.topic); } catch (const json::exception &e) { nhlog::db()->warn("failed to parse m.room.topic event: {}", e.what()); @@ -1756,7 +2010,7 @@ Cache::getRoomAvatar(const std::string &room_id) std::string media_url; try { - RoomInfo info = json::parse(std::string(response.data(), response.size())); + RoomInfo info = json::parse(std::string_view(response.data(), response.size())); media_url = std::move(info.avatar_url); if (media_url.empty()) { @@ -1953,30 +2207,438 @@ Cache::isRoomMember(const std::string &user_id, const std::string &room_id) } void +Cache::savePendingMessage(const std::string &room_id, + const mtx::events::collections::TimelineEvent &message) +{ + auto txn = lmdb::txn::begin(env_); + + mtx::responses::Timeline timeline; + timeline.events.push_back(message.data); + saveTimelineMessages(txn, room_id, timeline); + + auto pending = getPendingMessagesDb(txn, room_id); + + int64_t now = QDateTime::currentMSecsSinceEpoch(); + lmdb::dbi_put(txn, + pending, + lmdb::val(&now, sizeof(now)), + lmdb::val(mtx::accessors::event_id(message.data))); + + txn.commit(); +} + +std::optional<mtx::events::collections::TimelineEvent> +Cache::firstPendingMessage(const std::string &room_id) +{ + auto txn = lmdb::txn::begin(env_); + auto pending = getPendingMessagesDb(txn, room_id); + + { + auto pendingCursor = lmdb::cursor::open(txn, pending); + lmdb::val tsIgnored, pendingTxn; + while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) { + auto eventsDb = getEventsDb(txn, room_id); + lmdb::val event; + if (!lmdb::dbi_get(txn, eventsDb, pendingTxn, event)) { + lmdb::dbi_del(txn, pending, tsIgnored, pendingTxn); + continue; + } + + try { + mtx::events::collections::TimelineEvent te; + mtx::events::collections::from_json( + json::parse(std::string_view(event.data(), event.size())), te); + + pendingCursor.close(); + txn.commit(); + return te; + } catch (std::exception &e) { + nhlog::db()->error("Failed to parse message from cache {}", + e.what()); + lmdb::dbi_del(txn, pending, tsIgnored, pendingTxn); + continue; + } + } + } + + txn.commit(); + + return std::nullopt; +} + +void +Cache::removePendingStatus(const std::string &room_id, const std::string &txn_id) +{ + auto txn = lmdb::txn::begin(env_); + auto pending = getPendingMessagesDb(txn, room_id); + + { + auto pendingCursor = lmdb::cursor::open(txn, pending); + lmdb::val tsIgnored, pendingTxn; + while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) { + if (std::string_view(pendingTxn.data(), pendingTxn.size()) == txn_id) + lmdb::cursor_del(pendingCursor); + } + } + + txn.commit(); +} + +void Cache::saveTimelineMessages(lmdb::txn &txn, const std::string &room_id, const mtx::responses::Timeline &res) { - auto db = getMessagesDb(txn, room_id); + if (res.events.empty()) + return; + + auto eventsDb = getEventsDb(txn, room_id); + auto relationsDb = getRelationsDb(txn, room_id); + + auto orderDb = getEventOrderDb(txn, room_id); + auto evToOrderDb = getEventToOrderDb(txn, room_id); + auto msg2orderDb = getMessageToOrderDb(txn, room_id); + auto order2msgDb = getOrderToMessageDb(txn, room_id); + auto pending = getPendingMessagesDb(txn, room_id); + + if (res.limited) { + lmdb::dbi_drop(txn, orderDb, false); + lmdb::dbi_drop(txn, evToOrderDb, false); + lmdb::dbi_drop(txn, msg2orderDb, false); + lmdb::dbi_drop(txn, order2msgDb, false); + lmdb::dbi_drop(txn, pending, true); + } using namespace mtx::events; using namespace mtx::events::state; + lmdb::val indexVal, val; + uint64_t index = std::numeric_limits<uint64_t>::max() / 2; + auto cursor = lmdb::cursor::open(txn, orderDb); + if (cursor.get(indexVal, val, MDB_LAST)) { + index = *indexVal.data<int64_t>(); + } + + uint64_t msgIndex = std::numeric_limits<uint64_t>::max() / 2; + auto msgCursor = lmdb::cursor::open(txn, order2msgDb); + if (msgCursor.get(indexVal, val, MDB_LAST)) { + msgIndex = *indexVal.data<uint64_t>(); + } + + bool first = true; for (const auto &e : res.events) { - if (std::holds_alternative<RedactionEvent<msg::Redaction>>(e)) + auto event = mtx::accessors::serialize_event(e); + auto txn_id = mtx::accessors::transaction_id(e); + + std::string event_id_val = event.value("event_id", ""); + if (event_id_val.empty()) { + nhlog::db()->error("Event without id!"); continue; + } + + lmdb::val event_id = event_id_val; + + json orderEntry = json::object(); + orderEntry["event_id"] = event_id_val; + if (first && !res.prev_batch.empty()) + orderEntry["prev_batch"] = res.prev_batch; - json obj = json::object(); + lmdb::val txn_order; + if (!txn_id.empty() && + lmdb::dbi_get(txn, evToOrderDb, lmdb::val(txn_id), txn_order)) { + lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump())); + lmdb::dbi_del(txn, eventsDb, lmdb::val(txn_id)); - obj["event"] = mtx::accessors::serialize_event(e); - obj["token"] = res.prev_batch; + lmdb::val msg_txn_order; + if (lmdb::dbi_get(txn, msg2orderDb, lmdb::val(txn_id), msg_txn_order)) { + lmdb::dbi_put(txn, order2msgDb, msg_txn_order, event_id); + lmdb::dbi_put(txn, msg2orderDb, event_id, msg_txn_order); + lmdb::dbi_del(txn, msg2orderDb, lmdb::val(txn_id)); + } + + lmdb::dbi_put(txn, orderDb, txn_order, lmdb::val(orderEntry.dump())); + lmdb::dbi_put(txn, evToOrderDb, event_id, txn_order); + lmdb::dbi_del(txn, evToOrderDb, lmdb::val(txn_id)); + + if (event.contains("content") && + event["content"].contains("m.relates_to")) { + auto temp = event["content"]["m.relates_to"]; + std::string relates_to = temp.contains("m.in_reply_to") + ? temp["m.in_reply_to"]["event_id"] + : temp["event_id"]; + + if (!relates_to.empty()) { + lmdb::dbi_del(txn, + relationsDb, + lmdb::val(relates_to), + lmdb::val(txn_id)); + lmdb::dbi_put( + txn, relationsDb, lmdb::val(relates_to), event_id); + } + } + + auto pendingCursor = lmdb::cursor::open(txn, pending); + lmdb::val tsIgnored, pendingTxn; + while (pendingCursor.get(tsIgnored, pendingTxn, MDB_NEXT)) { + if (std::string_view(pendingTxn.data(), pendingTxn.size()) == + txn_id) + lmdb::cursor_del(pendingCursor); + } + } else if (auto redaction = + std::get_if<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>( + &e)) { + if (redaction->redacts.empty()) + continue; + + lmdb::val oldEvent; + bool success = + lmdb::dbi_get(txn, eventsDb, lmdb::val(redaction->redacts), oldEvent); + if (!success) + continue; + + mtx::events::collections::TimelineEvent te; + try { + mtx::events::collections::from_json( + json::parse(std::string_view(oldEvent.data(), oldEvent.size())), + te); + // overwrite the content and add redation data + std::visit( + [redaction](auto &ev) { + ev.unsigned_data.redacted_because = *redaction; + ev.unsigned_data.redacted_by = redaction->event_id; + }, + te.data); + event = mtx::accessors::serialize_event(te.data); + event["content"].clear(); + + } catch (std::exception &e) { + nhlog::db()->error("Failed to parse message from cache {}", + e.what()); + continue; + } + + lmdb::dbi_put( + txn, eventsDb, lmdb::val(redaction->redacts), lmdb::val(event.dump())); + lmdb::dbi_put(txn, + eventsDb, + lmdb::val(redaction->event_id), + lmdb::val(json(*redaction).dump())); + } else { + lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump())); + + ++index; + + first = false; + + nhlog::db()->debug("saving '{}'", orderEntry.dump()); + + lmdb::cursor_put(cursor.handle(), + lmdb::val(&index, sizeof(index)), + lmdb::val(orderEntry.dump()), + MDB_APPEND); + lmdb::dbi_put(txn, evToOrderDb, event_id, lmdb::val(&index, sizeof(index))); + + // TODO(Nico): Allow blacklisting more event types in UI + if (!isHiddenEvent(e, room_id)) { + ++msgIndex; + lmdb::cursor_put(msgCursor.handle(), + lmdb::val(&msgIndex, sizeof(msgIndex)), + event_id, + MDB_APPEND); + + lmdb::dbi_put(txn, + msg2orderDb, + event_id, + lmdb::val(&msgIndex, sizeof(msgIndex))); + } + + if (event.contains("content") && + event["content"].contains("m.relates_to")) { + auto temp = event["content"]["m.relates_to"]; + std::string relates_to = temp.contains("m.in_reply_to") + ? temp["m.in_reply_to"]["event_id"] + : temp["event_id"]; + + if (!relates_to.empty()) + lmdb::dbi_put( + txn, relationsDb, lmdb::val(relates_to), event_id); + } + } + } +} + +uint64_t +Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res) +{ + auto txn = lmdb::txn::begin(env_); + auto eventsDb = getEventsDb(txn, room_id); + auto relationsDb = getRelationsDb(txn, room_id); + + auto orderDb = getEventOrderDb(txn, room_id); + auto evToOrderDb = getEventToOrderDb(txn, room_id); + auto msg2orderDb = getMessageToOrderDb(txn, room_id); + auto order2msgDb = getOrderToMessageDb(txn, room_id); + + lmdb::val indexVal, val; + uint64_t index = std::numeric_limits<uint64_t>::max() / 2; + { + auto cursor = lmdb::cursor::open(txn, orderDb); + if (cursor.get(indexVal, val, MDB_FIRST)) { + index = *indexVal.data<uint64_t>(); + } + } + + uint64_t msgIndex = std::numeric_limits<uint64_t>::max() / 2; + { + auto msgCursor = lmdb::cursor::open(txn, order2msgDb); + if (msgCursor.get(indexVal, val, MDB_FIRST)) { + msgIndex = *indexVal.data<uint64_t>(); + } + } + + if (res.chunk.empty()) + return index; + + std::string event_id_val; + for (const auto &e : res.chunk) { + if (std::holds_alternative< + mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(e)) + continue; + + auto event = mtx::accessors::serialize_event(e); + event_id_val = event["event_id"].get<std::string>(); + lmdb::val event_id = event_id_val; + lmdb::dbi_put(txn, eventsDb, event_id, lmdb::val(event.dump())); + + --index; + + json orderEntry = json::object(); + orderEntry["event_id"] = event_id_val; + + nhlog::db()->debug("saving '{}'", orderEntry.dump()); lmdb::dbi_put( - txn, - db, - lmdb::val(std::to_string(obj["event"]["origin_server_ts"].get<uint64_t>())), - lmdb::val(obj.dump())); + txn, orderDb, lmdb::val(&index, sizeof(index)), lmdb::val(orderEntry.dump())); + lmdb::dbi_put(txn, evToOrderDb, event_id, lmdb::val(&index, sizeof(index))); + + // TODO(Nico): Allow blacklisting more event types in UI + if (!isHiddenEvent(e, room_id)) { + --msgIndex; + lmdb::dbi_put( + txn, order2msgDb, lmdb::val(&msgIndex, sizeof(msgIndex)), event_id); + + lmdb::dbi_put( + txn, msg2orderDb, event_id, lmdb::val(&msgIndex, sizeof(msgIndex))); + } + + if (event.contains("content") && event["content"].contains("m.relates_to")) { + auto temp = event["content"]["m.relates_to"]; + std::string relates_to = temp.contains("m.in_reply_to") + ? temp["m.in_reply_to"]["event_id"] + : temp["event_id"]; + + if (!relates_to.empty()) + lmdb::dbi_put(txn, relationsDb, lmdb::val(relates_to), event_id); + } + } + + json orderEntry = json::object(); + orderEntry["event_id"] = event_id_val; + orderEntry["prev_batch"] = res.end; + lmdb::dbi_put(txn, orderDb, lmdb::val(&index, sizeof(index)), lmdb::val(orderEntry.dump())); + nhlog::db()->debug("saving '{}'", orderEntry.dump()); + + txn.commit(); + + return msgIndex; +} + +void +Cache::clearTimeline(const std::string &room_id) +{ + auto txn = lmdb::txn::begin(env_); + auto eventsDb = getEventsDb(txn, room_id); + auto relationsDb = getRelationsDb(txn, room_id); + + auto orderDb = getEventOrderDb(txn, room_id); + auto evToOrderDb = getEventToOrderDb(txn, room_id); + auto msg2orderDb = getMessageToOrderDb(txn, room_id); + auto order2msgDb = getOrderToMessageDb(txn, room_id); + + lmdb::val indexVal, val; + auto cursor = lmdb::cursor::open(txn, orderDb); + + bool start = true; + bool passed_pagination_token = false; + while (cursor.get(indexVal, val, start ? MDB_LAST : MDB_PREV)) { + start = false; + json obj; + + try { + obj = json::parse(std::string_view(val.data(), val.size())); + } catch (std::exception &) { + // workaround bug in the initial db format, where we sometimes didn't store + // json... + obj = {{"event_id", std::string(val.data(), val.size())}}; + } + + if (passed_pagination_token) { + if (obj.count("event_id") != 0) { + lmdb::val event_id = obj["event_id"].get<std::string>(); + lmdb::dbi_del(txn, evToOrderDb, event_id); + lmdb::dbi_del(txn, eventsDb, event_id); + + lmdb::dbi_del(txn, relationsDb, event_id); + + lmdb::val order{}; + bool exists = lmdb::dbi_get(txn, msg2orderDb, event_id, order); + if (exists) { + lmdb::dbi_del(txn, order2msgDb, order); + lmdb::dbi_del(txn, msg2orderDb, event_id); + } + } + lmdb::cursor_del(cursor); + } else { + if (obj.count("prev_batch") != 0) + passed_pagination_token = true; + } + } + + auto msgCursor = lmdb::cursor::open(txn, order2msgDb); + start = true; + while (msgCursor.get(indexVal, val, start ? MDB_LAST : MDB_PREV)) { + start = false; + + lmdb::val eventId; + bool innerStart = true; + bool found = false; + while (cursor.get(indexVal, eventId, innerStart ? MDB_LAST : MDB_PREV)) { + innerStart = false; + + json obj; + try { + obj = json::parse(std::string_view(eventId.data(), eventId.size())); + } catch (std::exception &) { + obj = {{"event_id", std::string(eventId.data(), eventId.size())}}; + } + + if (obj["event_id"] == std::string(val.data(), val.size())) { + found = true; + break; + } + } + + if (!found) + break; } + + do { + lmdb::cursor_del(msgCursor); + } while (msgCursor.get(indexVal, val, MDB_PREV)); + + cursor.close(); + msgCursor.close(); + txn.commit(); } mtx::responses::Notifications @@ -2111,34 +2773,60 @@ Cache::getRoomIds(lmdb::txn &txn) void Cache::deleteOldMessages() { + lmdb::val indexVal, val; + auto txn = lmdb::txn::begin(env_); auto room_ids = getRoomIds(txn); - for (const auto &id : room_ids) { - auto msg_db = getMessagesDb(txn, id); - - std::string ts, event; - uint64_t idx = 0; + for (const auto &room_id : room_ids) { + auto orderDb = getEventOrderDb(txn, room_id); + auto evToOrderDb = getEventToOrderDb(txn, room_id); + auto o2m = getOrderToMessageDb(txn, room_id); + auto m2o = getMessageToOrderDb(txn, room_id); + auto eventsDb = getEventsDb(txn, room_id); + auto relationsDb = getRelationsDb(txn, room_id); + auto cursor = lmdb::cursor::open(txn, orderDb); + + uint64_t first, last; + if (cursor.get(indexVal, val, MDB_LAST)) { + last = *indexVal.data<uint64_t>(); + } else { + continue; + } + if (cursor.get(indexVal, val, MDB_FIRST)) { + first = *indexVal.data<uint64_t>(); + } else { + continue; + } - const auto db_size = msg_db.size(txn); - if (db_size <= 3 * MAX_RESTORED_MESSAGES) + size_t message_count = static_cast<size_t>(last - first); + if (message_count < MAX_RESTORED_MESSAGES) continue; - nhlog::db()->info("[{}] message count: {}", id, db_size); + bool start = true; + while (cursor.get(indexVal, val, start ? MDB_FIRST : MDB_NEXT) && + message_count-- > MAX_RESTORED_MESSAGES) { + start = false; + auto obj = json::parse(std::string_view(val.data(), val.size())); - auto cursor = lmdb::cursor::open(txn, msg_db); - while (cursor.get(ts, event, MDB_NEXT)) { - idx += 1; + if (obj.count("event_id") != 0) { + lmdb::val event_id = obj["event_id"].get<std::string>(); + lmdb::dbi_del(txn, evToOrderDb, event_id); + lmdb::dbi_del(txn, eventsDb, event_id); - if (idx > MAX_RESTORED_MESSAGES) - lmdb::cursor_del(cursor); - } + lmdb::dbi_del(txn, relationsDb, event_id); + lmdb::val order{}; + bool exists = lmdb::dbi_get(txn, m2o, event_id, order); + if (exists) { + lmdb::dbi_del(txn, o2m, order); + lmdb::dbi_del(txn, m2o, event_id); + } + } + lmdb::cursor_del(cursor); + } cursor.close(); - - nhlog::db()->info("[{}] updated message count: {}", id, msg_db.size(txn)); } - txn.commit(); } @@ -2172,7 +2860,7 @@ Cache::hasEnoughPowerLevel(const std::vector<mtx::events::EventType> &eventTypes if (res) { try { StateEvent<PowerLevels> msg = - json::parse(std::string(event.data(), event.size())); + json::parse(std::string_view(event.data(), event.size())); user_level = msg.content.user_level(user_id); @@ -2277,6 +2965,9 @@ Cache::removeAvatarUrl(const QString &room_id, const QString &user_id) mtx::presence::PresenceState Cache::presenceState(const std::string &user_id) { + if (user_id.empty()) + return {}; + lmdb::val presenceVal; auto txn = lmdb::txn::begin(env_); @@ -2287,7 +2978,7 @@ Cache::presenceState(const std::string &user_id) if (res) { mtx::events::presence::Presence presence = - json::parse(std::string(presenceVal.data(), presenceVal.size())); + json::parse(std::string_view(presenceVal.data(), presenceVal.size())); state = presence.presence; } @@ -2299,6 +2990,9 @@ Cache::presenceState(const std::string &user_id) std::string Cache::statusMessage(const std::string &user_id) { + if (user_id.empty()) + return {}; + lmdb::val presenceVal; auto txn = lmdb::txn::begin(env_); @@ -2309,7 +3003,7 @@ Cache::statusMessage(const std::string &user_id) if (res) { mtx::events::presence::Presence presence = - json::parse(std::string(presenceVal.data(), presenceVal.size())); + json::parse(std::string_view(presenceVal.data(), presenceVal.size())); status_msg = presence.status_msg; } diff --git a/src/Cache_p.h b/src/Cache_p.h index 892b66a5..d3ec6ee0 100644 --- a/src/Cache_p.h +++ b/src/Cache_p.h @@ -18,6 +18,7 @@ #pragma once +#include <limits> #include <optional> #include <QDateTime> @@ -38,9 +39,6 @@ #include "CacheCryptoStructs.h" #include "CacheStructs.h" -int -numeric_key_comparison(const MDB_val *a, const MDB_val *b); - class Cache : public QObject { Q_OBJECT @@ -172,6 +170,47 @@ public: //! Add all notifications containing a user mention to the db. void saveTimelineMentions(const mtx::responses::Notifications &res); + //! retrieve events in timeline and related functions + struct Messages + { + mtx::responses::Timeline timeline; + uint64_t next_index; + bool end_of_cache = false; + }; + Messages getTimelineMessages(lmdb::txn &txn, + const std::string &room_id, + uint64_t index = std::numeric_limits<uint64_t>::max(), + bool forward = false); + + std::optional<mtx::events::collections::TimelineEvent> getEvent( + const std::string &room_id, + const std::string &event_id); + void storeEvent(const std::string &room_id, + const std::string &event_id, + const mtx::events::collections::TimelineEvent &event); + std::vector<std::string> relatedEvents(const std::string &room_id, + const std::string &event_id); + + struct TimelineRange + { + uint64_t first, last; + }; + std::optional<TimelineRange> getTimelineRange(const std::string &room_id); + std::optional<uint64_t> getTimelineIndex(const std::string &room_id, + std::string_view event_id); + std::optional<std::string> getTimelineEventId(const std::string &room_id, uint64_t index); + + std::string previousBatchToken(const std::string &room_id); + uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res); + void savePendingMessage(const std::string &room_id, + const mtx::events::collections::TimelineEvent &message); + std::optional<mtx::events::collections::TimelineEvent> firstPendingMessage( + const std::string &room_id); + void removePendingStatus(const std::string &room_id, const std::string &txn_id); + + //! clear timeline keeping only the latest batch + void clearTimeline(const std::string &room_id); + //! Remove old unused data. void deleteOldMessages(); void deleteOldData() noexcept; @@ -250,8 +289,6 @@ private: const std::string &room_id, const mtx::responses::Timeline &res); - mtx::responses::Timeline getTimelineMessages(lmdb::txn &txn, const std::string &room_id); - //! Remove a room from the cache. // void removeLeftRoom(lmdb::txn &txn, const std::string &room_id); template<class T> @@ -402,13 +439,46 @@ private: return lmdb::dbi::open(txn, "pending_receipts", MDB_CREATE); } - lmdb::dbi getMessagesDb(lmdb::txn &txn, const std::string &room_id) + 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); + } + + lmdb::dbi getEventOrderDb(lmdb::txn &txn, const std::string &room_id) + { + return lmdb::dbi::open( + txn, std::string(room_id + "/event_order").c_str(), MDB_CREATE | MDB_INTEGERKEY); + } + + // inverse of EventOrderDb + lmdb::dbi getEventToOrderDb(lmdb::txn &txn, const std::string &room_id) + { + return lmdb::dbi::open( + txn, std::string(room_id + "/event2order").c_str(), MDB_CREATE); + } + + lmdb::dbi getMessageToOrderDb(lmdb::txn &txn, const std::string &room_id) + { + return lmdb::dbi::open( + txn, std::string(room_id + "/msg2order").c_str(), MDB_CREATE); + } + + lmdb::dbi getOrderToMessageDb(lmdb::txn &txn, const std::string &room_id) + { + return lmdb::dbi::open( + txn, std::string(room_id + "/order2msg").c_str(), MDB_CREATE | MDB_INTEGERKEY); + } + + lmdb::dbi getPendingMessagesDb(lmdb::txn &txn, const std::string &room_id) { - auto db = - lmdb::dbi::open(txn, std::string(room_id + "/messages").c_str(), MDB_CREATE); - lmdb::dbi_set_compare(txn, db, numeric_key_comparison); + return lmdb::dbi::open( + txn, std::string(room_id + "/pending").c_str(), MDB_CREATE | MDB_INTEGERKEY); + } - return db; + lmdb::dbi getRelationsDb(lmdb::txn &txn, const std::string &room_id) + { + return lmdb::dbi::open( + txn, std::string(room_id + "/related").c_str(), MDB_CREATE | MDB_DUPSORT); } lmdb::dbi getInviteStatesDb(lmdb::txn &txn, const std::string &room_id) diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index 84a5e4d3..e55b3eca 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -165,6 +165,11 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent) trySync(); }); + connect(text_input_, + &TextInputWidget::clearRoomTimeline, + view_manager_, + &TimelineViewManager::clearCurrentRoomTimeline); + connect( new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() { if (isVisible()) @@ -254,7 +259,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent) room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView); connect(room_list_, &RoomList::acceptInvite, this, [this](const QString &room_id) { - view_manager_->addRoom(room_id); joinRoom(room_id); room_list_->removeRoom(room_id, currentRoom() == room_id); }); @@ -323,17 +327,15 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent) .toStdString(); member.membership = mtx::events::state::Membership::Join; - http::client() - ->send_state_event<mtx::events::state::Member, - mtx::events::EventType::RoomMember>( - currentRoom().toStdString(), - http::client()->user_id().to_string(), - member, - [](mtx::responses::EventId, mtx::http::RequestErr err) { - if (err) - nhlog::net()->error("Failed to set room displayname: {}", - err->matrix_error.error); - }); + http::client()->send_state_event( + currentRoom().toStdString(), + http::client()->user_id().to_string(), + member, + [](mtx::responses::EventId, mtx::http::RequestErr err) { + if (err) + nhlog::net()->error("Failed to set room displayname: {}", + err->matrix_error.error); + }); }); connect( @@ -584,12 +586,8 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent) emit notificationsRetrieved(std::move(res)); }); }); - connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync, Qt::QueuedConnection); - connect(this, - &ChatPage::syncTags, - communitiesList_, - &CommunitiesList::syncTags, - Qt::QueuedConnection); + connect(this, &ChatPage::syncRoomlist, room_list_, &RoomList::sync); + connect(this, &ChatPage::syncTags, communitiesList_, &CommunitiesList::syncTags); connect( this, &ChatPage::syncTopBar, this, [this](const std::map<QString, RoomInfo> &updates) { if (updates.find(currentRoom()) != updates.end()) @@ -614,6 +612,12 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent) [this]() { QTimer::singleShot(RETRY_TIMEOUT, this, &ChatPage::trySync); }, Qt::QueuedConnection); + connect(this, + &ChatPage::newSyncResponse, + this, + &ChatPage::handleSyncResponse, + Qt::QueuedConnection); + connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage); connectCallMessage<mtx::events::msg::CallInvite>(); @@ -841,43 +845,39 @@ ChatPage::loadStateFromCache() nhlog::db()->info("restoring state from cache"); - getProfileInfo(); + try { + cache::restoreSessions(); + olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY); - QtConcurrent::run([this]() { - try { - cache::restoreSessions(); - olm::client()->load(cache::restoreOlmAccount(), STORAGE_SECRET_KEY); + cache::populateMembers(); - cache::populateMembers(); + emit initializeEmptyViews(cache::roomMessages()); + emit initializeRoomList(cache::roomInfo()); + emit initializeMentions(cache::getTimelineMentions()); + emit syncTags(cache::roomInfo().toStdMap()); - emit initializeEmptyViews(cache::roomMessages()); - emit initializeRoomList(cache::roomInfo()); - emit initializeMentions(cache::getTimelineMentions()); - emit syncTags(cache::roomInfo().toStdMap()); + cache::calculateRoomReadStatus(); - cache::calculateRoomReadStatus(); + } catch (const mtx::crypto::olm_exception &e) { + nhlog::crypto()->critical("failed to restore olm account: {}", e.what()); + emit dropToLoginPageCb(tr("Failed to restore OLM account. Please login again.")); + return; + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to restore cache: {}", e.what()); + emit dropToLoginPageCb(tr("Failed to restore save data. Please login again.")); + return; + } catch (const json::exception &e) { + nhlog::db()->critical("failed to parse cache data: {}", e.what()); + return; + } - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to restore olm account: {}", e.what()); - emit dropToLoginPageCb( - tr("Failed to restore OLM account. Please login again.")); - return; - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to restore cache: {}", e.what()); - emit dropToLoginPageCb( - tr("Failed to restore save data. Please login again.")); - return; - } catch (const json::exception &e) { - nhlog::db()->critical("failed to parse cache data: {}", e.what()); - return; - } + nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519); + nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519); - nhlog::crypto()->info("ed25519 : {}", olm::client()->identity_keys().ed25519); - nhlog::crypto()->info("curve25519: {}", olm::client()->identity_keys().curve25519); + getProfileInfo(); - // Start receiving events. - emit trySyncCb(); - }); + // Start receiving events. + emit trySyncCb(); } void @@ -1056,6 +1056,45 @@ ChatPage::startInitialSync() } void +ChatPage::handleSyncResponse(mtx::responses::Sync res) +{ + nhlog::net()->debug("sync completed: {}", res.next_batch); + + // Ensure that we have enough one-time keys available. + ensureOneTimeKeyCount(res.device_one_time_keys_count); + + // TODO: fine grained error handling + try { + cache::saveState(res); + olm::handle_to_device_messages(res.to_device.events); + + auto updates = cache::roomUpdates(res); + + emit syncTopBar(updates); + emit syncRoomlist(updates); + + emit syncUI(res.rooms); + + emit syncTags(cache::roomTagUpdates(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(); + } catch (const lmdb::error &e) { + nhlog::db()->error("saving sync response: {}", e.what()); + } + + emit trySyncCb(); +} + +void ChatPage::trySync() { mtx::http::SyncOpts opts; @@ -1072,7 +1111,14 @@ ChatPage::trySync() } http::client()->sync( - opts, [this](const mtx::responses::Sync &res, mtx::http::RequestErr err) { + opts, + [this, since = cache::nextBatchToken()](const mtx::responses::Sync &res, + mtx::http::RequestErr err) { + if (since != cache::nextBatchToken()) { + nhlog::net()->warn("Duplicate sync, dropping"); + return; + } + if (err) { const auto error = QString::fromStdString(err->matrix_error.error); const auto msg = tr("Please try to login again: %1").arg(error); @@ -1094,40 +1140,7 @@ ChatPage::trySync() return; } - nhlog::net()->debug("sync completed: {}", res.next_batch); - - // Ensure that we have enough one-time keys available. - ensureOneTimeKeyCount(res.device_one_time_keys_count); - - // TODO: fine grained error handling - try { - cache::saveState(res); - olm::handle_to_device_messages(res.to_device.events); - - auto updates = cache::roomUpdates(res); - - emit syncTopBar(updates); - emit syncRoomlist(updates); - - emit syncUI(res.rooms); - - emit syncTags(cache::roomTagUpdates(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(); - } catch (const lmdb::error &e) { - nhlog::db()->error("saving sync response: {}", e.what()); - } - - emit trySyncCb(); + emit newSyncResponse(res); }); } diff --git a/src/ChatPage.h b/src/ChatPage.h index fe63c9d9..ba1c56d1 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -140,6 +140,7 @@ signals: void trySyncCb(); void tryDelayedSyncCb(); void tryInitialSyncCb(); + void newSyncResponse(mtx::responses::Sync res); void leftRoom(const QString &room_id); void initializeRoomList(QMap<QString, RoomInfo>); @@ -174,6 +175,7 @@ private slots: void joinRoom(const QString &room); void sendTypingNotifications(); + void handleSyncResponse(mtx::responses::Sync res); private: static ChatPage *instance_; diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp index 7846737b..88612b14 100644 --- a/src/EventAccessors.cpp +++ b/src/EventAccessors.cpp @@ -248,6 +248,20 @@ struct EventInReplyTo } }; +struct EventRelatesTo +{ + template<class Content> + using related_ev_id_t = decltype(Content::relates_to.event_id); + template<class T> + std::string operator()(const mtx::events::Event<T> &e) + { + if constexpr (is_detected<related_ev_id_t, T>::value) { + return e.content.relates_to.event_id; + } + return ""; + } +}; + struct EventTransactionId { template<class T> @@ -409,6 +423,11 @@ mtx::accessors::in_reply_to_event(const mtx::events::collections::TimelineEvents { return std::visit(EventInReplyTo{}, event); } +std::string +mtx::accessors::relates_to_event_id(const mtx::events::collections::TimelineEvents &event) +{ + return std::visit(EventRelatesTo{}, event); +} std::string mtx::accessors::transaction_id(const mtx::events::collections::TimelineEvents &event) diff --git a/src/EventAccessors.h b/src/EventAccessors.h index fa70f3eb..0cdc5f89 100644 --- a/src/EventAccessors.h +++ b/src/EventAccessors.h @@ -56,6 +56,8 @@ mimetype(const mtx::events::collections::TimelineEvents &event); std::string in_reply_to_event(const mtx::events::collections::TimelineEvents &event); std::string +relates_to_event_id(const mtx::events::collections::TimelineEvents &event); +std::string transaction_id(const mtx::events::collections::TimelineEvents &event); int64_t diff --git a/src/Olm.cpp b/src/Olm.cpp index 994a3a67..e38e9ef7 100644 --- a/src/Olm.cpp +++ b/src/Olm.cpp @@ -3,6 +3,7 @@ #include "Olm.h" #include "Cache.h" +#include "Cache_p.h" #include "Logging.h" #include "MatrixClient.h" #include "Utils.h" @@ -316,32 +317,36 @@ send_key_request_for(const std::string &room_id, using namespace mtx::events; nhlog::crypto()->debug("sending key request: {}", json(e).dump(2)); - auto payload = json{{"action", "request"}, - {"request_id", http::client()->generate_txn_id()}, - {"requesting_device_id", http::client()->device_id()}, - {"body", - {{"algorithm", MEGOLM_ALGO}, - {"room_id", room_id}, - {"sender_key", e.content.sender_key}, - {"session_id", e.content.session_id}}}}; - - json body; - body["messages"][e.sender] = json::object(); - body["messages"][e.sender][e.content.device_id] = payload; - - nhlog::crypto()->debug("m.room_key_request: {}", body.dump(2)); - - http::client()->send_to_device("m.room_key_request", body, [e](mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to send " - "send_to_device " - "message: {}", - err->matrix_error.error); - } - nhlog::net()->info( - "m.room_key_request sent to {}:{}", e.sender, e.content.device_id); - }); + mtx::events::msg::KeyRequest request; + request.action = mtx::events::msg::RequestAction::Request; + request.algorithm = MEGOLM_ALGO; + request.room_id = room_id; + request.sender_key = e.content.sender_key; + request.session_id = e.content.session_id; + request.request_id = "key_request." + http::client()->generate_txn_id(); + request.requesting_device_id = http::client()->device_id(); + + nhlog::crypto()->debug("m.room_key_request: {}", json(request).dump(2)); + + std::map<mtx::identifiers::User, std::map<std::string, decltype(request)>> body; + body[mtx::identifiers::parse<mtx::identifiers::User>(e.sender)][e.content.device_id] = + request; + body[http::client()->user_id()]["*"] = request; + + http::client()->send_to_device( + http::client()->generate_txn_id(), body, [e](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to send " + "send_to_device " + "message: {}", + err->matrix_error.error); + } + + nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices", + e.sender, + e.content.device_id); + }); } void @@ -551,4 +556,50 @@ send_megolm_key_to_device(const std::string &user_id, }); } +DecryptionResult +decryptEvent(const MegolmSessionIndex &index, + const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event) +{ + try { + if (!cache::client()->inboundMegolmSessionExists(index)) { + return {DecryptionErrorCode::MissingSession, std::nullopt, std::nullopt}; + } + } catch (const lmdb::error &e) { + return {DecryptionErrorCode::DbError, e.what(), std::nullopt}; + } + + // TODO: Lookup index,event_id,origin_server_ts tuple for replay attack errors + // TODO: Verify sender_key + + std::string msg_str; + try { + auto session = cache::client()->getInboundMegolmSession(index); + auto res = olm::client()->decrypt_group_message(session, event.content.ciphertext); + msg_str = std::string((char *)res.data.data(), res.data.size()); + } catch (const lmdb::error &e) { + return {DecryptionErrorCode::DbError, e.what(), std::nullopt}; + } catch (const mtx::crypto::olm_exception &e) { + return {DecryptionErrorCode::DecryptionFailed, e.what(), std::nullopt}; + } + + // Add missing fields for the event. + json body = json::parse(msg_str); + body["event_id"] = event.event_id; + body["sender"] = event.sender; + body["origin_server_ts"] = event.origin_server_ts; + body["unsigned"] = event.unsigned_data; + + // relations are unencrypted in content... + if (json old_ev = event; old_ev["content"].count("m.relates_to") != 0) + body["content"]["m.relates_to"] = old_ev["content"]["m.relates_to"]; + + mtx::events::collections::TimelineEvent te; + try { + mtx::events::collections::from_json(body, te); + } catch (std::exception &e) { + return {DecryptionErrorCode::ParsingFailed, e.what(), std::nullopt}; + } + + return {std::nullopt, std::nullopt, std::move(te.data)}; +} } // namespace olm diff --git a/src/Olm.h b/src/Olm.h index 09038ad1..87f4e3ec 100644 --- a/src/Olm.h +++ b/src/Olm.h @@ -7,10 +7,30 @@ #include <mtx/events/encrypted.hpp> #include <mtxclient/crypto/client.hpp> +#include <CacheCryptoStructs.h> + constexpr auto OLM_ALGO = "m.olm.v1.curve25519-aes-sha2"; namespace olm { +enum class DecryptionErrorCode +{ + MissingSession, // Session was not found, retrieve from backup or request from other devices + // and try again + DbError, // DB read failed + DecryptionFailed, // libolm error + ParsingFailed, // Failed to parse the actual event + ReplayAttack, // Megolm index reused + UnknownFingerprint, // Unknown device Fingerprint +}; + +struct DecryptionResult +{ + std::optional<DecryptionErrorCode> error; + std::optional<std::string> error_message; + std::optional<mtx::events::collections::TimelineEvents> event; +}; + struct OlmMessage { std::string sender_key; @@ -65,6 +85,10 @@ encrypt_group_message(const std::string &room_id, const std::string &device_id, nlohmann::json body); +DecryptionResult +decryptEvent(const MegolmSessionIndex &index, + const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &event); + void mark_keys_as_published(); diff --git a/src/TextInputWidget.cpp b/src/TextInputWidget.cpp index 9af7de26..bdc430f5 100644 --- a/src/TextInputWidget.cpp +++ b/src/TextInputWidget.cpp @@ -683,27 +683,29 @@ void TextInputWidget::command(QString command, QString args) { if (command == "me") { - sendEmoteMessage(args); + emit sendEmoteMessage(args); } else if (command == "join") { - sendJoinRoomRequest(args); + emit sendJoinRoomRequest(args); } else if (command == "invite") { - sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); + emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "kick") { - sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); + emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "ban") { - sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); + emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "unban") { - sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); + emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1)); } else if (command == "roomnick") { - changeRoomNick(args); + emit changeRoomNick(args); } else if (command == "shrug") { - sendTextMessage("¯\\_(ツ)_/¯"); + emit sendTextMessage("¯\\_(ツ)_/¯"); } else if (command == "fliptable") { - sendTextMessage("(╯°□°)╯︵ ┻━┻"); + emit sendTextMessage("(╯°□°)╯︵ ┻━┻"); } else if (command == "unfliptable") { - sendTextMessage(" ┯━┯╭( º _ º╭)"); + emit sendTextMessage(" ┯━┯╭( º _ º╭)"); } else if (command == "sovietflip") { - sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\"); + emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\"); + } else if (command == "clear-timeline") { + emit clearRoomTimeline(); } } @@ -735,7 +737,7 @@ TextInputWidget::showUploadSpinner() topLayout_->removeWidget(sendFileBtn_); sendFileBtn_->hide(); - topLayout_->insertWidget(0, spinner_); + topLayout_->insertWidget(1, spinner_); spinner_->start(); } @@ -743,7 +745,7 @@ void TextInputWidget::hideUploadSpinner() { topLayout_->removeWidget(spinner_); - topLayout_->insertWidget(0, sendFileBtn_); + topLayout_->insertWidget(1, sendFileBtn_); sendFileBtn_->show(); spinner_->stop(); } diff --git a/src/TextInputWidget.h b/src/TextInputWidget.h index 9e70f498..8cd61b6a 100644 --- a/src/TextInputWidget.h +++ b/src/TextInputWidget.h @@ -180,6 +180,7 @@ private slots: signals: void sendTextMessage(const QString &msg); void sendEmoteMessage(QString msg); + void clearRoomTimeline(); void heightChanged(int height); void uploadMedia(const QSharedPointer<QIODevice> data, diff --git a/src/WebRTCSession.cpp b/src/WebRTCSession.cpp index 2248fb1a..f5dc49d8 100644 --- a/src/WebRTCSession.cpp +++ b/src/WebRTCSession.cpp @@ -223,18 +223,19 @@ addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED, { nhlog::ui()->debug("WebRTC: local candidate: (m-line:{}):{}", mlineIndex, candidate); +#if GST_CHECK_VERSION(1, 17, 0) + localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate}); + return; +#else if (WebRTCSession::instance().state() >= WebRTCSession::State::OFFERSENT) { emit WebRTCSession::instance().newICECandidate( {"audio", (uint16_t)mlineIndex, candidate}); return; } - localcandidates_.push_back({"audio", (uint16_t)mlineIndex, candidate}); - // GStreamer v1.16: webrtcbin's notify::ice-gathering-state triggers // GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE too early. Fixed in v1.17. // Use a 100ms timeout in the meantime -#if !GST_CHECK_VERSION(1, 17, 0) static guint timerid = 0; if (timerid) g_source_remove(timerid); @@ -282,11 +283,11 @@ linkNewPad(GstElement *decodebin G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe GstElement *resample = gst_element_factory_make("audioresample", nullptr); GstElement *sink = gst_element_factory_make("autoaudiosink", nullptr); gst_bin_add_many(GST_BIN(pipe), queue, convert, resample, sink, nullptr); + gst_element_link_many(queue, convert, resample, sink, nullptr); gst_element_sync_state_with_parent(queue); gst_element_sync_state_with_parent(convert); gst_element_sync_state_with_parent(resample); gst_element_sync_state_with_parent(sink); - gst_element_link_many(queue, convert, resample, sink, nullptr); queuepad = gst_element_get_static_pad(queue, "sink"); } diff --git a/src/dialogs/RoomSettings.cpp b/src/dialogs/RoomSettings.cpp index 26aece32..822b7218 100644 --- a/src/dialogs/RoomSettings.cpp +++ b/src/dialogs/RoomSettings.cpp @@ -151,7 +151,7 @@ EditModal::applyClicked() state::Name body; body.name = newName.toStdString(); - http::client()->send_state_event<state::Name, EventType::RoomName>( + http::client()->send_state_event( roomId_.toStdString(), body, [proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) { @@ -169,7 +169,7 @@ EditModal::applyClicked() state::Topic body; body.topic = newTopic.toStdString(); - http::client()->send_state_event<state::Topic, EventType::RoomTopic>( + http::client()->send_state_event( roomId_.toStdString(), body, [proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) { @@ -694,7 +694,7 @@ RoomSettings::updateAccessRules(const std::string &room_id, startLoadingSpinner(); resetErrorLabel(); - http::client()->send_state_event<state::JoinRules, EventType::RoomJoinRules>( + http::client()->send_state_event( room_id, join_rule, [this, room_id, guest_access](const mtx::responses::EventId &, @@ -708,7 +708,7 @@ RoomSettings::updateAccessRules(const std::string &room_id, return; } - http::client()->send_state_event<state::GuestAccess, EventType::RoomGuestAccess>( + http::client()->send_state_event( room_id, guest_access, [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { @@ -843,7 +843,7 @@ RoomSettings::updateAvatar() avatar_event.image_info.size = size; avatar_event.url = res.content_uri; - http::client()->send_state_event<state::Avatar, EventType::RoomAvatar>( + http::client()->send_state_event( room_id, avatar_event, [content = std::move(content), proxy = std::move(proxy)]( diff --git a/src/main.cpp b/src/main.cpp index 46691e6f..e02ffa36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -173,11 +173,12 @@ main(int argc, char *argv[]) QString lang = QLocale::system().name(); QTranslator qtTranslator; - qtTranslator.load("qt_" + lang, QLibraryInfo::location(QLibraryInfo::TranslationsPath)); + qtTranslator.load( + QLocale(), "qt", "_", QLibraryInfo::location(QLibraryInfo::TranslationsPath)); app.installTranslator(&qtTranslator); QTranslator appTranslator; - appTranslator.load("nheko_" + lang, ":/translations"); + appTranslator.load(QLocale(), "nheko", "_", ":/translations"); app.installTranslator(&appTranslator); MainWindow w; diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp new file mode 100644 index 00000000..fca1d31d --- /dev/null +++ b/src/timeline/EventStore.cpp @@ -0,0 +1,570 @@ +#include "EventStore.h" + +#include <QThread> +#include <QTimer> + +#include "Cache.h" +#include "Cache_p.h" +#include "EventAccessors.h" +#include "Logging.h" +#include "MatrixClient.h" +#include "Olm.h" + +Q_DECLARE_METATYPE(Reaction) + +QCache<EventStore::IdIndex, mtx::events::collections::TimelineEvents> EventStore::decryptedEvents_{ + 1000}; +QCache<EventStore::IdIndex, mtx::events::collections::TimelineEvents> EventStore::events_by_id_{ + 1000}; +QCache<EventStore::Index, mtx::events::collections::TimelineEvents> EventStore::events_{1000}; + +EventStore::EventStore(std::string room_id, QObject *) + : room_id_(std::move(room_id)) +{ + static auto reactionType = qRegisterMetaType<Reaction>(); + (void)reactionType; + + auto range = cache::client()->getTimelineRange(room_id_); + + if (range) { + this->first = range->first; + this->last = range->last; + } + + connect( + this, + &EventStore::eventFetched, + this, + [this](std::string id, + std::string relatedTo, + mtx::events::collections::TimelineEvents timeline) { + cache::client()->storeEvent(room_id_, id, {timeline}); + + if (!relatedTo.empty()) { + auto idx = idToIndex(relatedTo); + if (idx) + emit dataChanged(*idx, *idx); + } + }, + Qt::QueuedConnection); + + connect( + this, + &EventStore::oldMessagesRetrieved, + this, + [this](const mtx::responses::Messages &res) { + // + uint64_t newFirst = cache::client()->saveOldMessages(room_id_, res); + if (newFirst == first) + fetchMore(); + else { + emit beginInsertRows(toExternalIdx(newFirst), + toExternalIdx(this->first - 1)); + this->first = newFirst; + emit endInsertRows(); + emit fetchedMore(); + } + }, + Qt::QueuedConnection); + + connect(this, &EventStore::processPending, this, [this]() { + if (!current_txn.empty()) { + nhlog::ui()->debug("Already processing {}", current_txn); + return; + } + + auto event = cache::client()->firstPendingMessage(room_id_); + + if (!event) { + nhlog::ui()->debug("No event to send"); + return; + } + + std::visit( + [this](auto e) { + auto txn_id = e.event_id; + this->current_txn = txn_id; + + if (txn_id.empty() || txn_id[0] != 'm') { + nhlog::ui()->debug("Invalid txn id '{}'", txn_id); + cache::client()->removePendingStatus(room_id_, txn_id); + return; + } + + if constexpr (mtx::events::message_content_to_type<decltype(e.content)> != + mtx::events::EventType::Unsupported) + http::client()->send_room_message( + room_id_, + txn_id, + e.content, + [this, txn_id](const mtx::responses::EventId &event_id, + mtx::http::RequestErr err) { + if (err) { + const int status_code = + static_cast<int>(err->status_code); + nhlog::net()->warn( + "[{}] failed to send message: {} {}", + txn_id, + err->matrix_error.error, + status_code); + emit messageFailed(txn_id); + return; + } + emit messageSent(txn_id, event_id.event_id.to_string()); + }); + }, + event->data); + }); + + connect( + this, + &EventStore::messageFailed, + this, + [this](std::string txn_id) { + if (current_txn == txn_id) { + current_txn_error_count++; + if (current_txn_error_count > 10) { + nhlog::ui()->debug("failing txn id '{}'", txn_id); + cache::client()->removePendingStatus(room_id_, txn_id); + current_txn_error_count = 0; + } + } + QTimer::singleShot(1000, this, [this]() { + nhlog::ui()->debug("timeout"); + this->current_txn = ""; + emit processPending(); + }); + }, + Qt::QueuedConnection); + + connect( + this, + &EventStore::messageSent, + this, + [this](std::string txn_id, std::string event_id) { + nhlog::ui()->debug("sent {}", txn_id); + + http::client()->read_event( + room_id_, event_id, [this, event_id](mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn( + "failed to read_event ({}, {})", room_id_, event_id); + } + }); + + cache::client()->removePendingStatus(room_id_, txn_id); + this->current_txn = ""; + this->current_txn_error_count = 0; + emit processPending(); + }, + Qt::QueuedConnection); +} + +void +EventStore::addPending(mtx::events::collections::TimelineEvents event) +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + cache::client()->savePendingMessage(this->room_id_, {event}); + mtx::responses::Timeline events; + events.limited = false; + events.events.emplace_back(event); + handleSync(events); + + emit processPending(); +} + +void +EventStore::clearTimeline() +{ + emit beginResetModel(); + + cache::client()->clearTimeline(room_id_); + auto range = cache::client()->getTimelineRange(room_id_); + if (range) { + nhlog::db()->info("Range {} {}", range->last, range->first); + this->last = range->last; + this->first = range->first; + } else { + this->first = std::numeric_limits<uint64_t>::max(); + this->last = std::numeric_limits<uint64_t>::max(); + } + nhlog::ui()->info("Range {} {}", this->last, this->first); + + emit endResetModel(); +} + +void +EventStore::handleSync(const mtx::responses::Timeline &events) +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + auto range = cache::client()->getTimelineRange(room_id_); + if (!range) + return; + + if (events.limited) { + emit beginResetModel(); + this->last = range->last; + this->first = range->first; + emit endResetModel(); + + } else if (range->last > this->last) { + emit beginInsertRows(toExternalIdx(this->last + 1), toExternalIdx(range->last)); + this->last = range->last; + emit endInsertRows(); + } + + for (const auto &event : events.events) { + std::string relates_to; + if (auto redaction = + std::get_if<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>( + &event)) { + // fixup reactions + auto redacted = events_by_id_.object({room_id_, redaction->redacts}); + if (redacted) { + auto id = mtx::accessors::relates_to_event_id(*redacted); + if (!id.empty()) { + auto idx = idToIndex(id); + if (idx) { + events_by_id_.remove( + {room_id_, redaction->redacts}); + events_.remove({room_id_, toInternalIdx(*idx)}); + emit dataChanged(*idx, *idx); + } + } + } + + relates_to = redaction->redacts; + } else if (auto reaction = + std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>( + &event)) { + relates_to = reaction->content.relates_to.event_id; + } else { + relates_to = mtx::accessors::in_reply_to_event(event); + } + + if (!relates_to.empty()) { + auto idx = cache::client()->getTimelineIndex(room_id_, relates_to); + if (idx) { + events_by_id_.remove({room_id_, relates_to}); + decryptedEvents_.remove({room_id_, relates_to}); + events_.remove({room_id_, *idx}); + emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx)); + } + } + + if (auto txn_id = mtx::accessors::transaction_id(event); !txn_id.empty()) { + auto idx = cache::client()->getTimelineIndex( + room_id_, mtx::accessors::event_id(event)); + if (idx) { + Index index{room_id_, *idx}; + events_.remove(index); + emit dataChanged(toExternalIdx(*idx), toExternalIdx(*idx)); + } + } + } +} + +QVariantList +EventStore::reactions(const std::string &event_id) +{ + auto event_ids = cache::client()->relatedEvents(room_id_, event_id); + + struct TempReaction + { + int count = 0; + std::vector<std::string> users; + std::string reactedBySelf; + }; + std::map<std::string, TempReaction> aggregation; + std::vector<Reaction> reactions; + + auto self = http::client()->user_id().to_string(); + for (const auto &id : event_ids) { + auto related_event = get(id, event_id); + if (!related_event) + continue; + + if (auto reaction = std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>( + related_event)) { + auto &agg = aggregation[reaction->content.relates_to.key]; + + if (agg.count == 0) { + Reaction temp{}; + temp.key_ = + QString::fromStdString(reaction->content.relates_to.key); + reactions.push_back(temp); + } + + agg.count++; + agg.users.push_back(cache::displayName(room_id_, reaction->sender)); + if (reaction->sender == self) + agg.reactedBySelf = reaction->event_id; + } + } + + QVariantList temp; + for (auto &reaction : reactions) { + const auto &agg = aggregation[reaction.key_.toStdString()]; + reaction.count_ = agg.count; + reaction.selfReactedEvent_ = QString::fromStdString(agg.reactedBySelf); + + bool firstReaction = true; + for (const auto &user : agg.users) { + if (firstReaction) + firstReaction = false; + else + reaction.users_ += ", "; + + reaction.users_ += QString::fromStdString(user); + } + + nhlog::db()->debug("key: {}, count: {}, users: {}", + reaction.key_.toStdString(), + reaction.count_, + reaction.users_.toStdString()); + temp.append(QVariant::fromValue(reaction)); + } + + return temp; +} + +mtx::events::collections::TimelineEvents * +EventStore::get(int idx, bool decrypt) +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + Index index{room_id_, toInternalIdx(idx)}; + if (index.idx > last || index.idx < first) + return nullptr; + + auto event_ptr = events_.object(index); + if (!event_ptr) { + auto event_id = cache::client()->getTimelineEventId(room_id_, index.idx); + if (!event_id) + return nullptr; + + auto event = cache::client()->getEvent(room_id_, *event_id); + if (!event) + return nullptr; + else + event_ptr = + new mtx::events::collections::TimelineEvents(std::move(event->data)); + events_.insert(index, event_ptr); + } + + if (decrypt) + if (auto encrypted = + std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>( + event_ptr)) + return decryptEvent({room_id_, encrypted->event_id}, *encrypted); + + return event_ptr; +} + +std::optional<int> +EventStore::idToIndex(std::string_view id) const +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + auto idx = cache::client()->getTimelineIndex(room_id_, id); + if (idx) + return toExternalIdx(*idx); + else + return std::nullopt; +} +std::optional<std::string> +EventStore::indexToId(int idx) const +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + return cache::client()->getTimelineEventId(room_id_, toInternalIdx(idx)); +} + +mtx::events::collections::TimelineEvents * +EventStore::decryptEvent(const IdIndex &idx, + const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e) +{ + if (auto cachedEvent = decryptedEvents_.object(idx)) + return cachedEvent; + + MegolmSessionIndex index; + index.room_id = room_id_; + index.session_id = e.content.session_id; + index.sender_key = e.content.sender_key; + + auto asCacheEntry = [&idx](mtx::events::collections::TimelineEvents &&event) { + auto event_ptr = new mtx::events::collections::TimelineEvents(std::move(event)); + decryptedEvents_.insert(idx, event_ptr); + return event_ptr; + }; + + auto decryptionResult = olm::decryptEvent(index, e); + + if (decryptionResult.error) { + mtx::events::RoomEvent<mtx::events::msg::Notice> dummy; + dummy.origin_server_ts = e.origin_server_ts; + dummy.event_id = e.event_id; + dummy.sender = e.sender; + switch (*decryptionResult.error) { + case olm::DecryptionErrorCode::MissingSession: + dummy.content.body = + tr("-- Encrypted Event (No keys found for decryption) --", + "Placeholder, when the message was not decrypted yet or can't be " + "decrypted.") + .toStdString(); + nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", + index.room_id, + index.session_id, + e.sender); + // TODO: Check if this actually works and look in key backup + olm::send_key_request_for(room_id_, e); + break; + case olm::DecryptionErrorCode::DbError: + nhlog::db()->critical( + "failed to retrieve megolm session with index ({}, {}, {})", + index.room_id, + index.session_id, + index.sender_key, + decryptionResult.error_message.value_or("")); + dummy.content.body = + tr("-- Decryption Error (failed to retrieve megolm keys from db) --", + "Placeholder, when the message can't be decrypted, because the DB " + "access " + "failed.") + .toStdString(); + break; + case olm::DecryptionErrorCode::DecryptionFailed: + nhlog::crypto()->critical( + "failed to decrypt message with index ({}, {}, {}): {}", + index.room_id, + index.session_id, + index.sender_key, + decryptionResult.error_message.value_or("")); + dummy.content.body = + tr("-- Decryption Error (%1) --", + "Placeholder, when the message can't be decrypted. In this case, the " + "Olm " + "decrytion returned an error, which is passed as %1.") + .arg( + QString::fromStdString(decryptionResult.error_message.value_or(""))) + .toStdString(); + break; + case olm::DecryptionErrorCode::ParsingFailed: + dummy.content.body = + tr("-- Encrypted Event (Unknown event type) --", + "Placeholder, when the message was decrypted, but we couldn't parse " + "it, because " + "Nheko/mtxclient don't support that event type yet.") + .toStdString(); + break; + case olm::DecryptionErrorCode::ReplayAttack: + nhlog::crypto()->critical( + "Reply attack while decryptiong event {} in room {} from {}!", + e.event_id, + room_id_, + index.sender_key); + dummy.content.body = + tr("-- Reply attack! This message index was reused! --").toStdString(); + break; + case olm::DecryptionErrorCode::UnknownFingerprint: + // TODO: don't fail, just show in UI. + nhlog::crypto()->critical("Message by unverified fingerprint {}", + index.sender_key); + dummy.content.body = + tr("-- Message by unverified device! --").toStdString(); + break; + } + return asCacheEntry(std::move(dummy)); + } + + auto encInfo = mtx::accessors::file(decryptionResult.event.value()); + if (encInfo) + emit newEncryptedImage(encInfo.value()); + + return asCacheEntry(std::move(decryptionResult.event.value())); +} + +mtx::events::collections::TimelineEvents * +EventStore::get(std::string_view id, std::string_view related_to, bool decrypt) +{ + if (this->thread() != QThread::currentThread()) + nhlog::db()->warn("{} called from a different thread!", __func__); + + if (id.empty()) + return nullptr; + + IdIndex index{room_id_, std::string(id.data(), id.size())}; + + auto event_ptr = events_by_id_.object(index); + if (!event_ptr) { + auto event = cache::client()->getEvent(room_id_, index.id); + if (!event) { + http::client()->get_event( + room_id_, + index.id, + [this, + relatedTo = std::string(related_to.data(), related_to.size()), + id = index.id](const mtx::events::collections::TimelineEvents &timeline, + mtx::http::RequestErr err) { + if (err) { + nhlog::net()->error( + "Failed to retrieve event with id {}, which was " + "requested to show the replyTo for event {}", + relatedTo, + id); + return; + } + emit eventFetched(id, relatedTo, timeline); + }); + return nullptr; + } + event_ptr = new mtx::events::collections::TimelineEvents(std::move(event->data)); + events_by_id_.insert(index, event_ptr); + } + + if (decrypt) + if (auto encrypted = + std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>( + event_ptr)) + return decryptEvent(index, *encrypted); + + return event_ptr; +} + +void +EventStore::fetchMore() +{ + mtx::http::MessagesOpts opts; + opts.room_id = room_id_; + opts.from = cache::client()->previousBatchToken(room_id_); + + nhlog::ui()->debug("Paginating room {}, token {}", opts.room_id, opts.from); + + http::client()->messages( + opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { + if (cache::client()->previousBatchToken(room_id_) != opts.from) { + nhlog::net()->warn("Cache cleared while fetching more messages, dropping " + "/messages response"); + emit fetchedMore(); + return; + } + if (err) { + nhlog::net()->error("failed to call /messages ({}): {} - {} - {}", + opts.room_id, + mtx::errors::to_string(err->matrix_error.errcode), + err->matrix_error.error, + err->parse_error); + emit fetchedMore(); + return; + } + + emit oldMessagesRetrieved(std::move(res)); + }); +} diff --git a/src/timeline/EventStore.h b/src/timeline/EventStore.h new file mode 100644 index 00000000..d4353a18 --- /dev/null +++ b/src/timeline/EventStore.h @@ -0,0 +1,122 @@ +#pragma once + +#include <limits> +#include <string> + +#include <QCache> +#include <QObject> +#include <QVariant> +#include <qhashfunctions.h> + +#include <mtx/events/collections.hpp> +#include <mtx/responses/messages.hpp> +#include <mtx/responses/sync.hpp> + +#include "Reaction.h" + +class EventStore : public QObject +{ + Q_OBJECT + +public: + EventStore(std::string room_id, QObject *parent); + + struct Index + { + std::string room; + uint64_t idx; + + friend uint qHash(const Index &i, uint seed = 0) noexcept + { + QtPrivate::QHashCombine hash; + seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size())); + seed = hash(seed, i.idx); + return seed; + } + + friend bool operator==(const Index &a, const Index &b) noexcept + { + return a.idx == b.idx && a.room == b.room; + } + }; + struct IdIndex + { + std::string room, id; + + friend uint qHash(const IdIndex &i, uint seed = 0) noexcept + { + QtPrivate::QHashCombine hash; + seed = hash(seed, QByteArray::fromRawData(i.room.data(), i.room.size())); + seed = hash(seed, QByteArray::fromRawData(i.id.data(), i.id.size())); + return seed; + } + + friend bool operator==(const IdIndex &a, const IdIndex &b) noexcept + { + return a.id == b.id && a.room == b.room; + } + }; + + void fetchMore(); + void handleSync(const mtx::responses::Timeline &events); + + // optionally returns the event or nullptr and fetches it, after which it emits a + // relatedFetched event + mtx::events::collections::TimelineEvents *get(std::string_view id, + std::string_view related_to, + bool decrypt = true); + // always returns a proper event as long as the idx is valid + mtx::events::collections::TimelineEvents *get(int idx, bool decrypt = true); + + QVariantList reactions(const std::string &event_id); + + int size() const + { + return last != std::numeric_limits<uint64_t>::max() + ? static_cast<int>(last - first) + 1 + : 0; + } + int toExternalIdx(uint64_t idx) const { return static_cast<int>(idx - first); } + uint64_t toInternalIdx(int idx) const { return first + idx; } + + std::optional<int> idToIndex(std::string_view id) const; + std::optional<std::string> indexToId(int idx) const; + +signals: + void beginInsertRows(int from, int to); + void endInsertRows(); + void beginResetModel(); + void endResetModel(); + void dataChanged(int from, int to); + void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo); + void eventFetched(std::string id, + std::string relatedTo, + mtx::events::collections::TimelineEvents timeline); + void oldMessagesRetrieved(const mtx::responses::Messages &); + void fetchedMore(); + + void processPending(); + void messageSent(std::string txn_id, std::string event_id); + void messageFailed(std::string txn_id); + +public slots: + void addPending(mtx::events::collections::TimelineEvents event); + void clearTimeline(); + +private: + mtx::events::collections::TimelineEvents *decryptEvent( + const IdIndex &idx, + const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e); + + std::string room_id_; + + uint64_t first = std::numeric_limits<uint64_t>::max(), + last = std::numeric_limits<uint64_t>::max(); + + static QCache<IdIndex, mtx::events::collections::TimelineEvents> decryptedEvents_; + static QCache<Index, mtx::events::collections::TimelineEvents> events_; + static QCache<IdIndex, mtx::events::collections::TimelineEvents> events_by_id_; + + std::string current_txn; + int current_txn_error_count = 0; +}; diff --git a/src/timeline/Reaction.cpp b/src/timeline/Reaction.cpp new file mode 100644 index 00000000..343c4649 --- /dev/null +++ b/src/timeline/Reaction.cpp @@ -0,0 +1 @@ +#include "Reaction.h" diff --git a/src/timeline/Reaction.h b/src/timeline/Reaction.h new file mode 100644 index 00000000..5f122e0a --- /dev/null +++ b/src/timeline/Reaction.h @@ -0,0 +1,24 @@ +#pragma once + +#include <QObject> +#include <QString> + +struct Reaction +{ + Q_GADGET + Q_PROPERTY(QString key READ key) + Q_PROPERTY(QString users READ users) + Q_PROPERTY(QString selfReactedEvent READ selfReactedEvent) + Q_PROPERTY(int count READ count) + +public: + QString key() const { return key_; } + QString users() const { return users_; } + QString selfReactedEvent() const { return selfReactedEvent_; } + int count() const { return count_; } + + QString key_; + QString users_; + QString selfReactedEvent_; + int count_; +}; diff --git a/src/timeline/ReactionsModel.cpp b/src/timeline/ReactionsModel.cpp deleted file mode 100644 index 1200e2ba..00000000 --- a/src/timeline/ReactionsModel.cpp +++ /dev/null @@ -1,98 +0,0 @@ -#include "ReactionsModel.h" - -#include <Cache.h> -#include <MatrixClient.h> - -QHash<int, QByteArray> -ReactionsModel::roleNames() const -{ - return { - {Key, "key"}, - {Count, "counter"}, - {Users, "users"}, - {SelfReactedEvent, "selfReactedEvent"}, - }; -} - -int -ReactionsModel::rowCount(const QModelIndex &) const -{ - return static_cast<int>(reactions.size()); -} - -QVariant -ReactionsModel::data(const QModelIndex &index, int role) const -{ - const int i = index.row(); - if (i < 0 || i >= static_cast<int>(reactions.size())) - return {}; - - switch (role) { - case Key: - return QString::fromStdString(reactions[i].key); - case Count: - return static_cast<int>(reactions[i].reactions.size()); - case Users: { - QString users; - bool first = true; - for (const auto &reaction : reactions[i].reactions) { - if (!first) - users += ", "; - else - first = false; - users += QString::fromStdString( - cache::displayName(room_id_, reaction.second.sender)); - } - return users; - } - case SelfReactedEvent: - for (const auto &reaction : reactions[i].reactions) - if (reaction.second.sender == http::client()->user_id().to_string()) - return QString::fromStdString(reaction.second.event_id); - return QStringLiteral(""); - default: - return {}; - } -} - -void -ReactionsModel::addReaction(const std::string &room_id, - const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction) -{ - room_id_ = room_id; - - int idx = 0; - for (auto &storedReactions : reactions) { - if (storedReactions.key == reaction.content.relates_to.key) { - storedReactions.reactions[reaction.event_id] = reaction; - emit dataChanged(index(idx, 0), index(idx, 0)); - return; - } - idx++; - } - - beginInsertRows(QModelIndex(), idx, idx); - reactions.push_back( - KeyReaction{reaction.content.relates_to.key, {{reaction.event_id, reaction}}}); - endInsertRows(); -} - -void -ReactionsModel::removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction) -{ - int idx = 0; - for (auto &storedReactions : reactions) { - if (storedReactions.key == reaction.content.relates_to.key) { - storedReactions.reactions.erase(reaction.event_id); - - if (storedReactions.reactions.size() == 0) { - beginRemoveRows(QModelIndex(), idx, idx); - reactions.erase(reactions.begin() + idx); - endRemoveRows(); - } else - emit dataChanged(index(idx, 0), index(idx, 0)); - return; - } - idx++; - } -} diff --git a/src/timeline/ReactionsModel.h b/src/timeline/ReactionsModel.h deleted file mode 100644 index c839afc8..00000000 --- a/src/timeline/ReactionsModel.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once - -#include <QAbstractListModel> -#include <QHash> - -#include <utility> -#include <vector> - -#include <mtx/events/collections.hpp> - -class ReactionsModel : public QAbstractListModel -{ - Q_OBJECT -public: - explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); } - enum Roles - { - Key, - Count, - Users, - SelfReactedEvent, - }; - - QHash<int, QByteArray> roleNames() const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - -public slots: - void addReaction(const std::string &room_id, - const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction); - void removeReaction(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &reaction); - -private: - struct KeyReaction - { - std::string key; - std::map<std::string, mtx::events::RoomEvent<mtx::events::msg::Reaction>> reactions; - }; - std::string room_id_; - std::vector<KeyReaction> reactions; -}; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 67e07d7b..b6c2d4bb 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -136,6 +136,11 @@ struct RoomEventType { return qml_mtx_events::EventType::CallHangUp; } + qml_mtx_events::EventType operator()( + const mtx::events::Event<mtx::events::msg::CallCandidates> &) + { + return qml_mtx_events::EventType::CallCandidates; + } // ::EventType::Type operator()(const Event<mtx::events::msg::Location> &e) { return // ::EventType::LocationMessage; } }; @@ -156,70 +161,10 @@ toRoomEventTypeString(const mtx::events::collections::TimelineEvents &event) TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObject *parent) : QAbstractListModel(parent) + , events(room_id.toStdString(), this) , room_id_(room_id) , manager_(manager) { - connect(this, - &TimelineModel::oldMessagesRetrieved, - this, - &TimelineModel::addBackwardsEvents, - Qt::QueuedConnection); - connect( - this, - &TimelineModel::messageFailed, - this, - [this](QString txn_id) { - nhlog::ui()->error("Failed to send {}, retrying", txn_id.toStdString()); - - QTimer::singleShot(5000, this, [this]() { emit nextPendingMessage(); }); - }, - Qt::QueuedConnection); - connect( - this, - &TimelineModel::messageSent, - this, - [this](QString txn_id, QString event_id) { - pending.removeOne(txn_id); - - auto ev = events.value(txn_id); - - if (auto reaction = - std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&ev)) { - QString reactedTo = - QString::fromStdString(reaction->content.relates_to.event_id); - auto &rModel = reactions[reactedTo]; - rModel.removeReaction(*reaction); - auto rCopy = *reaction; - rCopy.event_id = event_id.toStdString(); - rModel.addReaction(room_id_.toStdString(), rCopy); - } - - int idx = idToIndex(txn_id); - if (idx < 0) { - // transaction already received via sync - return; - } - eventOrder[idx] = event_id; - ev = std::visit( - [event_id](const auto &e) -> mtx::events::collections::TimelineEvents { - auto eventCopy = e; - eventCopy.event_id = event_id.toStdString(); - return eventCopy; - }, - ev); - - events.remove(txn_id); - events.insert(event_id, ev); - - // mark our messages as read - readEvent(event_id.toStdString()); - - emit dataChanged(index(idx, 0), index(idx, 0)); - - if (pending.size() > 0) - emit nextPendingMessage(); - }, - Qt::QueuedConnection); connect( this, &TimelineModel::redactionFailed, @@ -228,27 +173,44 @@ TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObj Qt::QueuedConnection); connect(this, - &TimelineModel::nextPendingMessage, - this, - &TimelineModel::processOnePendingMessage, - Qt::QueuedConnection); - connect(this, &TimelineModel::newMessageToSend, this, &TimelineModel::addPendingMessage, Qt::QueuedConnection); + connect(this, &TimelineModel::addPendingMessageToStore, &events, &EventStore::addPending); connect( + &events, + &EventStore::dataChanged, this, - &TimelineModel::eventFetched, - this, - [this](QString requestingEvent, mtx::events::collections::TimelineEvents event) { - events.insert(QString::fromStdString(mtx::accessors::event_id(event)), event); - auto idx = idToIndex(requestingEvent); - if (idx >= 0) - emit dataChanged(index(idx, 0), index(idx, 0)); + [this](int from, int to) { + nhlog::ui()->debug( + "data changed {} to {}", events.size() - to - 1, events.size() - from - 1); + emit dataChanged(index(events.size() - to - 1, 0), + index(events.size() - from - 1, 0)); }, Qt::QueuedConnection); + + connect(&events, &EventStore::beginInsertRows, this, [this](int from, int to) { + int first = events.size() - to; + int last = events.size() - from; + if (from >= events.size()) { + int batch_size = to - from; + first += batch_size; + last += batch_size; + } else { + first -= 1; + last -= 1; + } + nhlog::ui()->debug("begin insert from {} to {}", first, last); + beginInsertRows(QModelIndex(), first, last); + }); + connect(&events, &EventStore::endInsertRows, this, [this]() { endInsertRows(); }); + connect(&events, &EventStore::beginResetModel, this, [this]() { beginResetModel(); }); + connect(&events, &EventStore::endResetModel, this, [this]() { endResetModel(); }); + connect(&events, &EventStore::newEncryptedImage, this, &TimelineModel::newEncryptedImage); + connect( + &events, &EventStore::fetchedMore, this, [this]() { setPaginationInProgress(false); }); } QHash<int, QByteArray> @@ -290,28 +252,22 @@ int TimelineModel::rowCount(const QModelIndex &parent) const { Q_UNUSED(parent); - return (int)this->eventOrder.size(); + return this->events.size(); } QVariantMap -TimelineModel::getDump(QString eventId) const +TimelineModel::getDump(QString eventId, QString relatedTo) const { - if (events.contains(eventId)) - return data(eventId, Dump).toMap(); + if (auto event = events.get(eventId.toStdString(), relatedTo.toStdString())) + return data(*event, Dump).toMap(); return {}; } QVariant -TimelineModel::data(const QString &id, int role) const +TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int role) const { using namespace mtx::accessors; - namespace acc = mtx::accessors; - mtx::events::collections::TimelineEvents event = events.value(id); - - if (auto e = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) { - event = decryptEvent(*e).event; - } + namespace acc = mtx::accessors; switch (role) { case UserId: @@ -397,8 +353,9 @@ TimelineModel::data(const QString &id, int role) const return QVariant(prop > 0 ? prop : 1.); } case Id: - return id; + return QVariant(QString::fromStdString(event_id(event))); case State: { + auto id = QString::fromStdString(event_id(event)); auto containsOthers = [](const auto &vec) { for (const auto &e : vec) if (e.second != http::client()->user_id().to_string()) @@ -409,7 +366,7 @@ TimelineModel::data(const QString &id, int role) const // only show read receipts for messages not from us if (acc::sender(event) != http::client()->user_id().to_string()) return qml_mtx_events::Empty; - else if (pending.contains(id)) + else if (!id.isEmpty() && id[0] == "m") return qml_mtx_events::Sent; else if (read.contains(id) || containsOthers(cache::readReceipts(id, room_id_))) return qml_mtx_events::Read; @@ -417,19 +374,22 @@ TimelineModel::data(const QString &id, int role) const return qml_mtx_events::Received; } case IsEncrypted: { - return std::holds_alternative< - mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(events[id]); + auto id = event_id(event); + auto encrypted_event = events.get(id, id, false); + return encrypted_event && + std::holds_alternative< + mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>( + *encrypted_event); } case IsRoomEncrypted: { return cache::isRoomEncrypted(room_id_.toStdString()); } case ReplyTo: return QVariant(QString::fromStdString(in_reply_to_event(event))); - case Reactions: - if (reactions.count(id)) - return QVariant::fromValue((QObject *)&reactions.at(id)); - else - return {}; + case Reactions: { + auto id = event_id(event); + return QVariant::fromValue(events.reactions(id)); + } case RoomId: return QVariant(room_id_); case RoomName: @@ -443,31 +403,32 @@ TimelineModel::data(const QString &id, int role) const auto names = roleNames(); // m.insert(names[Section], data(id, static_cast<int>(Section))); - m.insert(names[Type], data(id, static_cast<int>(Type))); - m.insert(names[TypeString], data(id, static_cast<int>(TypeString))); - m.insert(names[IsOnlyEmoji], data(id, static_cast<int>(IsOnlyEmoji))); - m.insert(names[Body], data(id, static_cast<int>(Body))); - m.insert(names[FormattedBody], data(id, static_cast<int>(FormattedBody))); - m.insert(names[UserId], data(id, static_cast<int>(UserId))); - m.insert(names[UserName], data(id, static_cast<int>(UserName))); - m.insert(names[Timestamp], data(id, static_cast<int>(Timestamp))); - m.insert(names[Url], data(id, static_cast<int>(Url))); - m.insert(names[ThumbnailUrl], data(id, static_cast<int>(ThumbnailUrl))); - m.insert(names[Blurhash], data(id, static_cast<int>(Blurhash))); - m.insert(names[Filename], data(id, static_cast<int>(Filename))); - m.insert(names[Filesize], data(id, static_cast<int>(Filesize))); - m.insert(names[MimeType], data(id, static_cast<int>(MimeType))); - m.insert(names[Height], data(id, static_cast<int>(Height))); - m.insert(names[Width], data(id, static_cast<int>(Width))); - m.insert(names[ProportionalHeight], data(id, static_cast<int>(ProportionalHeight))); - m.insert(names[Id], data(id, static_cast<int>(Id))); - m.insert(names[State], data(id, static_cast<int>(State))); - m.insert(names[IsEncrypted], data(id, static_cast<int>(IsEncrypted))); - m.insert(names[IsRoomEncrypted], data(id, static_cast<int>(IsRoomEncrypted))); - m.insert(names[ReplyTo], data(id, static_cast<int>(ReplyTo))); - m.insert(names[RoomName], data(id, static_cast<int>(RoomName))); - m.insert(names[RoomTopic], data(id, static_cast<int>(RoomTopic))); - m.insert(names[CallType], data(id, static_cast<int>(CallType))); + m.insert(names[Type], data(event, static_cast<int>(Type))); + m.insert(names[TypeString], data(event, static_cast<int>(TypeString))); + m.insert(names[IsOnlyEmoji], data(event, static_cast<int>(IsOnlyEmoji))); + m.insert(names[Body], data(event, static_cast<int>(Body))); + m.insert(names[FormattedBody], data(event, static_cast<int>(FormattedBody))); + m.insert(names[UserId], data(event, static_cast<int>(UserId))); + m.insert(names[UserName], data(event, static_cast<int>(UserName))); + m.insert(names[Timestamp], data(event, static_cast<int>(Timestamp))); + m.insert(names[Url], data(event, static_cast<int>(Url))); + m.insert(names[ThumbnailUrl], data(event, static_cast<int>(ThumbnailUrl))); + m.insert(names[Blurhash], data(event, static_cast<int>(Blurhash))); + m.insert(names[Filename], data(event, static_cast<int>(Filename))); + m.insert(names[Filesize], data(event, static_cast<int>(Filesize))); + m.insert(names[MimeType], data(event, static_cast<int>(MimeType))); + m.insert(names[Height], data(event, static_cast<int>(Height))); + m.insert(names[Width], data(event, static_cast<int>(Width))); + m.insert(names[ProportionalHeight], + data(event, static_cast<int>(ProportionalHeight))); + m.insert(names[Id], data(event, static_cast<int>(Id))); + m.insert(names[State], data(event, static_cast<int>(State))); + m.insert(names[IsEncrypted], data(event, static_cast<int>(IsEncrypted))); + m.insert(names[IsRoomEncrypted], data(event, static_cast<int>(IsRoomEncrypted))); + m.insert(names[ReplyTo], data(event, static_cast<int>(ReplyTo))); + m.insert(names[RoomName], data(event, static_cast<int>(RoomName))); + m.insert(names[RoomTopic], data(event, static_cast<int>(RoomTopic))); + m.insert(names[CallType], data(event, static_cast<int>(CallType))); return QVariant(m); } @@ -481,29 +442,33 @@ TimelineModel::data(const QModelIndex &index, int role) const { using namespace mtx::accessors; namespace acc = mtx::accessors; - if (index.row() < 0 && index.row() >= (int)eventOrder.size()) + if (index.row() < 0 && index.row() >= rowCount()) return QVariant(); - QString id = eventOrder[index.row()]; + auto event = events.get(rowCount() - index.row() - 1); - mtx::events::collections::TimelineEvents event = events.value(id); + if (!event) + return ""; if (role == Section) { - QDateTime date = origin_server_ts(event); + QDateTime date = origin_server_ts(*event); date.setTime(QTime()); - std::string userId = acc::sender(event); + std::string userId = acc::sender(*event); + + for (int r = rowCount() - index.row(); r < events.size(); r++) { + auto tempEv = events.get(r); + if (!tempEv) + break; - for (size_t r = index.row() + 1; r < eventOrder.size(); r++) { - auto tempEv = events.value(eventOrder[r]); - QDateTime prevDate = origin_server_ts(tempEv); + QDateTime prevDate = origin_server_ts(*tempEv); prevDate.setTime(QTime()); if (prevDate != date) return QString("%2 %1") .arg(date.toMSecsSinceEpoch()) .arg(QString::fromStdString(userId)); - std::string prevUserId = acc::sender(tempEv); + std::string prevUserId = acc::sender(*tempEv); if (userId != prevUserId) break; } @@ -511,16 +476,17 @@ TimelineModel::data(const QModelIndex &index, int role) const return QString("%1").arg(QString::fromStdString(userId)); } - return data(id, role); + return data(*event, role); } bool TimelineModel::canFetchMore(const QModelIndex &) const { - if (eventOrder.empty()) + if (!events.size()) return true; - if (!std::holds_alternative<mtx::events::StateEvent<mtx::events::state::Create>>( - events[eventOrder.back()])) + if (auto first = events.get(0); + first && + !std::holds_alternative<mtx::events::StateEvent<mtx::events::state::Create>>(*first)) return true; else @@ -547,50 +513,44 @@ TimelineModel::fetchMore(const QModelIndex &) } setPaginationInProgress(true); - mtx::http::MessagesOpts opts; - opts.room_id = room_id_.toStdString(); - opts.from = prev_batch_token_.toStdString(); - nhlog::ui()->debug("Paginating room {}", opts.room_id); - - http::client()->messages( - opts, [this, opts](const mtx::responses::Messages &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error("failed to call /messages ({}): {} - {} - {}", - opts.room_id, - mtx::errors::to_string(err->matrix_error.errcode), - err->matrix_error.error, - err->parse_error); - setPaginationInProgress(false); - return; - } - - emit oldMessagesRetrieved(std::move(res)); - setPaginationInProgress(false); - }); + events.fetchMore(); } void TimelineModel::addEvents(const mtx::responses::Timeline &timeline) { - if (isInitialSync) { - prev_batch_token_ = QString::fromStdString(timeline.prev_batch); - isInitialSync = false; - } - if (timeline.events.empty()) return; - std::vector<QString> ids = internalAddEvents(timeline.events, true); + events.handleSync(timeline); - if (!ids.empty()) { - beginInsertRows(QModelIndex(), 0, static_cast<int>(ids.size() - 1)); - this->eventOrder.insert(this->eventOrder.begin(), ids.rbegin(), ids.rend()); - endInsertRows(); - } + using namespace mtx::events; + for (auto e : timeline.events) { + if (auto encryptedEvent = std::get_if<EncryptedEvent<msg::Encrypted>>(&e)) { + MegolmSessionIndex index; + index.room_id = room_id_.toStdString(); + index.session_id = encryptedEvent->content.session_id; + index.sender_key = encryptedEvent->content.sender_key; + + auto result = olm::decryptEvent(index, *encryptedEvent); + if (result.event) + e = result.event.value(); + } - if (!timeline.events.empty()) - updateLastMessage(); + if (std::holds_alternative<RoomEvent<msg::CallCandidates>>(e) || + std::holds_alternative<RoomEvent<msg::CallInvite>>(e) || + std::holds_alternative<RoomEvent<msg::CallAnswer>>(e) || + std::holds_alternative<RoomEvent<msg::CallHangUp>>(e)) + std::visit( + [this](auto &event) { + event.room_id = room_id_.toStdString(); + if (event.sender != http::client()->user_id().to_string()) + emit newCallEvent(event); + }, + e); + } + updateLastMessage(); } template<typename T> @@ -649,21 +609,17 @@ isYourJoin(const mtx::events::Event<T> &) void TimelineModel::updateLastMessage() { - for (auto it = eventOrder.begin(); it != eventOrder.end(); ++it) { - auto event = events.value(*it); - if (auto e = std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>( - &event)) { - if (decryptDescription) { - event = decryptEvent(*e).event; - } - } + for (auto it = events.size() - 1; it >= 0; --it) { + auto event = events.get(it, decryptDescription); + if (!event) + continue; - if (std::visit([](const auto &e) -> bool { return isYourJoin(e); }, event)) { - auto time = mtx::accessors::origin_server_ts(event); + if (std::visit([](const auto &e) -> bool { return isYourJoin(e); }, *event)) { + auto time = mtx::accessors::origin_server_ts(*event); uint64_t ts = time.toMSecsSinceEpoch(); emit manager_->updateRoomsLastMessage( room_id_, - DescInfo{QString::fromStdString(mtx::accessors::event_id(event)), + DescInfo{QString::fromStdString(mtx::accessors::event_id(*event)), QString::fromStdString(http::client()->user_id().to_string()), tr("You joined this room."), utils::descriptiveTime(time), @@ -671,191 +627,16 @@ TimelineModel::updateLastMessage() time}); return; } - if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, event)) + if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, *event)) continue; auto description = utils::getMessageDescription( - event, QString::fromStdString(http::client()->user_id().to_string()), room_id_); + *event, QString::fromStdString(http::client()->user_id().to_string()), room_id_); emit manager_->updateRoomsLastMessage(room_id_, description); return; } } -std::vector<QString> -TimelineModel::internalAddEvents( - const std::vector<mtx::events::collections::TimelineEvents> &timeline, - bool emitCallEvents) -{ - std::vector<QString> ids; - for (auto e : timeline) { - QString id = QString::fromStdString(mtx::accessors::event_id(e)); - - if (this->events.contains(id)) { - this->events.insert(id, e); - int idx = idToIndex(id); - emit dataChanged(index(idx, 0), index(idx, 0)); - continue; - } - - QString txid = QString::fromStdString(mtx::accessors::transaction_id(e)); - if (this->pending.removeOne(txid)) { - this->events.insert(id, e); - this->events.remove(txid); - int idx = idToIndex(txid); - if (idx < 0) { - nhlog::ui()->warn("Received index out of range"); - continue; - } - eventOrder[idx] = id; - emit dataChanged(index(idx, 0), index(idx, 0)); - continue; - } - - if (auto redaction = - std::get_if<mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(&e)) { - QString redacts = QString::fromStdString(redaction->redacts); - auto redacted = std::find(eventOrder.begin(), eventOrder.end(), redacts); - - auto event = events.value(redacts); - if (auto reaction = - std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>( - &event)) { - QString reactedTo = - QString::fromStdString(reaction->content.relates_to.event_id); - reactions[reactedTo].removeReaction(*reaction); - int idx = idToIndex(reactedTo); - if (idx >= 0) - emit dataChanged(index(idx, 0), index(idx, 0)); - } - - if (redacted != eventOrder.end()) { - auto redactedEvent = std::visit( - [](const auto &ev) - -> mtx::events::RoomEvent<mtx::events::msg::Redacted> { - mtx::events::RoomEvent<mtx::events::msg::Redacted> - replacement = {}; - replacement.event_id = ev.event_id; - replacement.room_id = ev.room_id; - replacement.sender = ev.sender; - replacement.origin_server_ts = ev.origin_server_ts; - replacement.type = ev.type; - return replacement; - }, - e); - events.insert(redacts, redactedEvent); - - int row = (int)std::distance(eventOrder.begin(), redacted); - emit dataChanged(index(row, 0), index(row, 0)); - } - - continue; // don't insert redaction into timeline - } - - if (auto reaction = - std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&e)) { - QString reactedTo = - QString::fromStdString(reaction->content.relates_to.event_id); - events.insert(id, e); - - // remove local echo - if (!txid.isEmpty()) { - auto rCopy = *reaction; - rCopy.event_id = txid.toStdString(); - reactions[reactedTo].removeReaction(rCopy); - } - - reactions[reactedTo].addReaction(room_id_.toStdString(), *reaction); - int idx = idToIndex(reactedTo); - if (idx >= 0) - emit dataChanged(index(idx, 0), index(idx, 0)); - continue; // don't insert reaction into timeline - } - - if (auto event = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&e)) { - auto e_ = decryptEvent(*event).event; - auto encInfo = mtx::accessors::file(e_); - - if (encInfo) - emit newEncryptedImage(encInfo.value()); - - if (std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallCandidates>>(e_)) { - // don't display CallCandidate events to user - events.insert(id, e); - if (emitCallEvents) - emit newCallEvent(e_); - continue; - } - - if (emitCallEvents) { - if (auto callInvite = std::get_if< - mtx::events::RoomEvent<mtx::events::msg::CallInvite>>(&e_)) { - callInvite->room_id = room_id_.toStdString(); - emit newCallEvent(e_); - } else if (std::holds_alternative<mtx::events::RoomEvent< - mtx::events::msg::CallCandidates>>(e_) || - std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallAnswer>>( - e_) || - std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallHangUp>>( - e_)) { - emit newCallEvent(e_); - } - } - } - - if (std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallCandidates>>(e)) { - // don't display CallCandidate events to user - events.insert(id, e); - if (emitCallEvents) - emit newCallEvent(e); - continue; - } - - if (emitCallEvents) { - if (auto callInvite = - std::get_if<mtx::events::RoomEvent<mtx::events::msg::CallInvite>>( - &e)) { - callInvite->room_id = room_id_.toStdString(); - emit newCallEvent(e); - } else if (std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallAnswer>>(e) || - std::holds_alternative< - mtx::events::RoomEvent<mtx::events::msg::CallHangUp>>(e)) { - emit newCallEvent(e); - } - } - - this->events.insert(id, e); - ids.push_back(id); - - auto replyTo = mtx::accessors::in_reply_to_event(e); - auto qReplyTo = QString::fromStdString(replyTo); - if (!replyTo.empty() && !events.contains(qReplyTo)) { - http::client()->get_event( - this->room_id_.toStdString(), - replyTo, - [this, id, replyTo]( - const mtx::events::collections::TimelineEvents &timeline, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error( - "Failed to retrieve event with id {}, which was " - "requested to show the replyTo for event {}", - replyTo, - id.toStdString()); - return; - } - emit eventFetched(id, timeline); - }); - } - } - return ids; -} - void TimelineModel::setCurrentIndex(int index) { @@ -863,7 +644,7 @@ TimelineModel::setCurrentIndex(int index) currentId = indexToId(index); emit currentIndexChanged(index); - if ((oldIndex > index || oldIndex == -1) && !pending.contains(currentId) && + if ((oldIndex > index || oldIndex == -1) && !currentId.startsWith("m") && ChatPage::instance()->isActiveWindow()) { readEvent(currentId.toStdString()); } @@ -881,27 +662,6 @@ TimelineModel::readEvent(const std::string &id) }); } -void -TimelineModel::addBackwardsEvents(const mtx::responses::Messages &msgs) -{ - std::vector<QString> ids = internalAddEvents(msgs.chunk, false); - - if (!ids.empty()) { - beginInsertRows(QModelIndex(), - static_cast<int>(this->eventOrder.size()), - static_cast<int>(this->eventOrder.size() + ids.size() - 1)); - this->eventOrder.insert(this->eventOrder.end(), ids.begin(), ids.end()); - endInsertRows(); - } - - prev_batch_token_ = QString::fromStdString(msgs.end); - - if (ids.empty() && !msgs.chunk.empty()) { - // no visible events fetched, prevent loading from stopping - fetchMore(QModelIndex()); - } -} - QString TimelineModel::displayName(QString id) const { @@ -938,7 +698,10 @@ TimelineModel::escapeEmoji(QString str) const void TimelineModel::viewRawMessage(QString id) const { - std::string ev = mtx::accessors::serialize_event(events.value(id)).dump(4); + auto e = events.get(id.toStdString(), "", false); + if (!e) + return; + std::string ev = mtx::accessors::serialize_event(*e).dump(4); auto dialog = new dialogs::RawMessage(QString::fromStdString(ev)); Q_UNUSED(dialog); } @@ -946,13 +709,11 @@ TimelineModel::viewRawMessage(QString id) const void TimelineModel::viewDecryptedRawMessage(QString id) const { - auto event = events.value(id); - if (auto e = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) { - event = decryptEvent(*e).event; - } + auto e = events.get(id.toStdString(), ""); + if (!e) + return; - std::string ev = mtx::accessors::serialize_event(event).dump(4); + std::string ev = mtx::accessors::serialize_event(*e).dump(4); auto dialog = new dialogs::RawMessage(QString::fromStdString(ev)); Q_UNUSED(dialog); } @@ -963,114 +724,6 @@ TimelineModel::openUserProfile(QString userid) const MainWindow::instance()->openUserProfile(userid, room_id_); } -DecryptionResult -TimelineModel::decryptEvent(const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e) const -{ - static QCache<std::string, DecryptionResult> decryptedEvents{300}; - - if (auto cachedEvent = decryptedEvents.object(e.event_id)) - return *cachedEvent; - - MegolmSessionIndex index; - index.room_id = room_id_.toStdString(); - index.session_id = e.content.session_id; - index.sender_key = e.content.sender_key; - - mtx::events::RoomEvent<mtx::events::msg::Notice> dummy; - dummy.origin_server_ts = e.origin_server_ts; - dummy.event_id = e.event_id; - dummy.sender = e.sender; - dummy.content.body = - tr("-- Encrypted Event (No keys found for decryption) --", - "Placeholder, when the message was not decrypted yet or can't be decrypted.") - .toStdString(); - - try { - if (!cache::inboundMegolmSessionExists(index)) { - nhlog::crypto()->info("Could not find inbound megolm session ({}, {}, {})", - index.room_id, - index.session_id, - e.sender); - // TODO: request megolm session_id & session_key from the sender. - decryptedEvents.insert( - dummy.event_id, new DecryptionResult{dummy, false}, 1); - return {dummy, false}; - } - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to check megolm session's existence: {}", e.what()); - dummy.content.body = tr("-- Decryption Error (failed to communicate with DB) --", - "Placeholder, when the message can't be decrypted, because " - "the DB access failed when trying to lookup the session.") - .toStdString(); - decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1); - return {dummy, false}; - } - - std::string msg_str; - try { - auto session = cache::getInboundMegolmSession(index); - auto res = olm::client()->decrypt_group_message(session, e.content.ciphertext); - msg_str = std::string((char *)res.data.data(), res.data.size()); - } catch (const lmdb::error &e) { - nhlog::db()->critical("failed to retrieve megolm session with index ({}, {}, {})", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = - tr("-- Decryption Error (failed to retrieve megolm keys from db) --", - "Placeholder, when the message can't be decrypted, because the DB access " - "failed.") - .toStdString(); - decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1); - return {dummy, false}; - } catch (const mtx::crypto::olm_exception &e) { - nhlog::crypto()->critical("failed to decrypt message with index ({}, {}, {}): {}", - index.room_id, - index.session_id, - index.sender_key, - e.what()); - dummy.content.body = - tr("-- Decryption Error (%1) --", - "Placeholder, when the message can't be decrypted. In this case, the Olm " - "decrytion returned an error, which is passed ad %1.") - .arg(e.what()) - .toStdString(); - decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1); - return {dummy, false}; - } - - // Add missing fields for the event. - json body = json::parse(msg_str); - body["event_id"] = e.event_id; - body["sender"] = e.sender; - body["origin_server_ts"] = e.origin_server_ts; - body["unsigned"] = e.unsigned_data; - - // relations are unencrypted in content... - if (json old_ev = e; old_ev["content"].count("m.relates_to") != 0) - body["content"]["m.relates_to"] = old_ev["content"]["m.relates_to"]; - - json event_array = json::array(); - event_array.push_back(body); - - std::vector<mtx::events::collections::TimelineEvents> temp_events; - mtx::responses::utils::parse_timeline_events(event_array, temp_events); - - if (temp_events.size() == 1) { - decryptedEvents.insert(e.event_id, new DecryptionResult{temp_events[0], true}, 1); - return {temp_events[0], true}; - } - - dummy.content.body = - tr("-- Encrypted Event (Unknown event type) --", - "Placeholder, when the message was decrypted, but we couldn't parse it, because " - "Nheko/mtxclient don't support that event type yet.") - .toStdString(); - decryptedEvents.insert(dummy.event_id, new DecryptionResult{dummy, false}, 1); - return {dummy, false}; -} - void TimelineModel::replyAction(QString id) { @@ -1081,23 +734,18 @@ TimelineModel::replyAction(QString id) RelatedInfo TimelineModel::relatedInfo(QString id) { - if (!events.contains(id)) + auto event = events.get(id.toStdString(), ""); + if (!event) return {}; - auto event = events.value(id); - if (auto e = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) { - event = decryptEvent(*e).event; - } - RelatedInfo related = {}; - related.quoted_user = QString::fromStdString(mtx::accessors::sender(event)); - related.related_event = mtx::accessors::event_id(event); - related.type = mtx::accessors::msg_type(event); + related.quoted_user = QString::fromStdString(mtx::accessors::sender(*event)); + related.related_event = mtx::accessors::event_id(*event); + related.type = mtx::accessors::msg_type(*event); // get body, strip reply fallback, then transform the event to text, if it is a media event // etc - related.quoted_body = QString::fromStdString(mtx::accessors::body(event)); + related.quoted_body = QString::fromStdString(mtx::accessors::body(*event)); QRegularExpression plainQuote("^>.*?$\n?", QRegularExpression::MultilineOption); while (related.quoted_body.startsWith(">")) related.quoted_body.remove(plainQuote); @@ -1106,7 +754,7 @@ TimelineModel::relatedInfo(QString id) related.quoted_body = utils::getQuoteBody(related); // get quoted body and strip reply fallback - related.quoted_formatted_body = mtx::accessors::formattedBodyWithFallback(event); + related.quoted_formatted_body = mtx::accessors::formattedBodyWithFallback(*event); related.quoted_formatted_body.remove(QRegularExpression( "<mx-reply>.*</mx-reply>", QRegularExpression::DotMatchesEverythingOption)); related.room = room_id_; @@ -1144,18 +792,19 @@ TimelineModel::idToIndex(QString id) const { if (id.isEmpty()) return -1; - for (int i = 0; i < (int)eventOrder.size(); i++) - if (id == eventOrder[i]) - return i; - return -1; + + auto idx = events.idToIndex(id.toStdString()); + if (idx) + return events.size() - *idx - 1; + else + return -1; } QString TimelineModel::indexToId(int index) const { - if (index < 0 || index >= (int)eventOrder.size()) - return ""; - return eventOrder[index]; + auto id = events.indexToId(events.size() - index - 1); + return id ? QString::fromStdString(*id) : ""; } // Note: this will only be called for our messages @@ -1189,28 +838,16 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id, try { // Check if we have already an outbound megolm session then we can use. if (cache::outboundMegolmSessionExists(room_id)) { - auto data = + mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> event; + event.content = olm::encrypt_group_message(room_id, http::client()->device_id(), doc); + event.event_id = txn_id; + event.room_id = room_id; + event.sender = http::client()->user_id().to_string(); + event.type = mtx::events::EventType::RoomEncrypted; + event.origin_server_ts = QDateTime::currentMSecsSinceEpoch(); - http::client()->send_room_message<msg::Encrypted, EventType::RoomEncrypted>( - room_id, - txn_id, - data, - [this, txn_id](const mtx::responses::EventId &res, - mtx::http::RequestErr err) { - if (err) { - const int status_code = - static_cast<int>(err->status_code); - nhlog::net()->warn("[{}] failed to send message: {} {}", - txn_id, - err->matrix_error.error, - status_code); - emit messageFailed(QString::fromStdString(txn_id)); - } - emit messageSent( - QString::fromStdString(txn_id), - QString::fromStdString(res.event_id.to_string())); - }); + emit this->addPendingMessageToStore(event); return; } @@ -1239,40 +876,25 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id, const auto members = cache::roomMembers(room_id); nhlog::ui()->info("retrieved {} members for {}", members.size(), room_id); - auto keeper = - std::make_shared<StateKeeper>([megolm_payload, room_id, doc, txn_id, this]() { - try { - auto data = olm::encrypt_group_message( - room_id, http::client()->device_id(), doc); - - http::client() - ->send_room_message<msg::Encrypted, EventType::RoomEncrypted>( - room_id, - txn_id, - data, - [this, txn_id](const mtx::responses::EventId &res, - mtx::http::RequestErr err) { - if (err) { - const int status_code = - static_cast<int>(err->status_code); - nhlog::net()->warn( - "[{}] failed to send message: {} {}", - txn_id, - err->matrix_error.error, - status_code); - emit messageFailed( - QString::fromStdString(txn_id)); - } - emit messageSent( - QString::fromStdString(txn_id), - QString::fromStdString(res.event_id.to_string())); - }); - } catch (const lmdb::error &e) { - nhlog::db()->critical( - "failed to save megolm outbound session: {}", e.what()); - emit messageFailed(QString::fromStdString(txn_id)); - } - }); + auto keeper = std::make_shared<StateKeeper>([room_id, doc, txn_id, this]() { + try { + mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> event; + event.content = olm::encrypt_group_message( + room_id, http::client()->device_id(), doc); + event.event_id = txn_id; + event.room_id = room_id; + event.sender = http::client()->user_id().to_string(); + event.type = mtx::events::EventType::RoomEncrypted; + event.origin_server_ts = QDateTime::currentMSecsSinceEpoch(); + + emit this->addPendingMessageToStore(event); + } catch (const lmdb::error &e) { + nhlog::db()->critical("failed to save megolm outbound session: {}", + e.what()); + emit ChatPage::instance()->showNotification( + tr("Failed to encrypt event, sending aborted!")); + } + }); mtx::requests::QueryKeys req; for (const auto &member : members) @@ -1286,8 +908,8 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id, nhlog::net()->warn("failed to query device keys: {} {}", err->matrix_error.error, static_cast<int>(err->status_code)); - // TODO: Mark the event as failed. Communicate with the UI. - emit messageFailed(QString::fromStdString(txn_id)); + emit ChatPage::instance()->showNotification( + tr("Failed to encrypt event, sending aborted!")); return; } @@ -1387,11 +1009,13 @@ TimelineModel::sendEncryptedMessageEvent(const std::string &txn_id, } catch (const lmdb::error &e) { nhlog::db()->critical( "failed to open outbound megolm session ({}): {}", room_id, e.what()); - emit messageFailed(QString::fromStdString(txn_id)); + emit ChatPage::instance()->showNotification( + tr("Failed to encrypt event, sending aborted!")); } catch (const mtx::crypto::olm_exception &e) { nhlog::crypto()->critical( "failed to open outbound megolm session ({}): {}", room_id, e.what()); - emit messageFailed(QString::fromStdString(txn_id)); + emit ChatPage::instance()->showNotification( + tr("Failed to encrypt event, sending aborted!")); } } @@ -1483,13 +1107,12 @@ TimelineModel::handleClaimedKeys(std::shared_ptr<StateKeeper> keeper, struct SendMessageVisitor { - SendMessageVisitor(const QString &txn_id, TimelineModel *model) - : txn_id_qstr_(txn_id) - , model_(model) + explicit SendMessageVisitor(TimelineModel *model) + : model_(model) {} template<typename T, mtx::events::EventType Event> - void sendRoomEvent(const mtx::events::RoomEvent<T> &msg) + void sendRoomEvent(mtx::events::RoomEvent<T> msg) { if (cache::isRoomEncrypted(model_->room_id_.toStdString())) { auto encInfo = mtx::accessors::file(msg); @@ -1497,36 +1120,13 @@ struct SendMessageVisitor emit model_->newEncryptedImage(encInfo.value()); model_->sendEncryptedMessageEvent( - txn_id_qstr_.toStdString(), nlohmann::json(msg.content), Event); + msg.event_id, nlohmann::json(msg.content), Event); } else { - sendUnencryptedRoomEvent<T, Event>(msg); + msg.type = Event; + emit model_->addPendingMessageToStore(msg); } } - template<typename T, mtx::events::EventType Event> - void sendUnencryptedRoomEvent(const mtx::events::RoomEvent<T> &msg) - { - QString txn_id_qstr = txn_id_qstr_; - TimelineModel *model = model_; - http::client()->send_room_message<T, Event>( - model->room_id_.toStdString(), - txn_id_qstr.toStdString(), - msg.content, - [txn_id_qstr, model](const mtx::responses::EventId &res, - mtx::http::RequestErr err) { - if (err) { - const int status_code = static_cast<int>(err->status_code); - nhlog::net()->warn("[{}] failed to send message: {} {}", - txn_id_qstr.toStdString(), - err->matrix_error.error, - status_code); - emit model->messageFailed(txn_id_qstr); - } - emit model->messageSent(txn_id_qstr, - QString::fromStdString(res.event_id.to_string())); - }); - } - // Do-nothing operator for all unhandled events template<typename T> void operator()(const mtx::events::Event<T> &) @@ -1535,7 +1135,7 @@ struct SendMessageVisitor // Operator for m.room.message events that contain a msgtype in their content template<typename T, std::enable_if_t<std::is_same<decltype(T::msgtype), std::string>::value, int> = 0> - void operator()(const mtx::events::RoomEvent<T> &msg) + void operator()(mtx::events::RoomEvent<T> msg) { sendRoomEvent<T, mtx::events::EventType::RoomMessage>(msg); } @@ -1545,10 +1145,10 @@ struct SendMessageVisitor // reactions need to have the relation outside of ciphertext, or synapse / the homeserver // cannot handle it correctly. See the MSC for more details: // https://github.com/matrix-org/matrix-doc/blob/matthew/msc1849/proposals/1849-aggregations.md#end-to-end-encryption - void operator()(const mtx::events::RoomEvent<mtx::events::msg::Reaction> &msg) + void operator()(mtx::events::RoomEvent<mtx::events::msg::Reaction> msg) { - sendUnencryptedRoomEvent<mtx::events::msg::Reaction, - mtx::events::EventType::Reaction>(msg); + msg.type = mtx::events::EventType::Reaction; + emit model_->addPendingMessageToStore(msg); } void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &event) @@ -1575,65 +1175,38 @@ struct SendMessageVisitor event); } - QString txn_id_qstr_; TimelineModel *model_; }; void -TimelineModel::processOnePendingMessage() -{ - if (pending.isEmpty()) - return; - - QString txn_id_qstr = pending.first(); - - auto event = events.value(txn_id_qstr); - std::visit(SendMessageVisitor{txn_id_qstr, this}, event); -} - -void TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event) { std::visit( [](auto &msg) { - msg.event_id = http::client()->generate_txn_id(); + msg.type = mtx::events::EventType::RoomMessage; + msg.event_id = "m" + http::client()->generate_txn_id(); msg.sender = http::client()->user_id().to_string(); msg.origin_server_ts = QDateTime::currentMSecsSinceEpoch(); }, event); - internalAddEvents({event}, false); - - QString txn_id_qstr = QString::fromStdString(mtx::accessors::event_id(event)); - pending.push_back(txn_id_qstr); - if (!std::get_if<mtx::events::RoomEvent<mtx::events::msg::Reaction>>(&event) && - !std::get_if<mtx::events::RoomEvent<mtx::events::msg::CallCandidates>>(&event)) { - beginInsertRows(QModelIndex(), 0, 0); - this->eventOrder.insert(this->eventOrder.begin(), txn_id_qstr); - endInsertRows(); - } - updateLastMessage(); - - emit nextPendingMessage(); + std::visit(SendMessageVisitor{this}, event); } bool TimelineModel::saveMedia(QString eventId) const { - mtx::events::collections::TimelineEvents event = events.value(eventId); - - if (auto e = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) { - event = decryptEvent(*e).event; - } + mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), ""); + if (!event) + return false; - QString mxcUrl = QString::fromStdString(mtx::accessors::url(event)); - QString originalFilename = QString::fromStdString(mtx::accessors::filename(event)); - QString mimeType = QString::fromStdString(mtx::accessors::mimetype(event)); + QString mxcUrl = QString::fromStdString(mtx::accessors::url(*event)); + QString originalFilename = QString::fromStdString(mtx::accessors::filename(*event)); + QString mimeType = QString::fromStdString(mtx::accessors::mimetype(*event)); - auto encryptionInfo = mtx::accessors::file(event); + auto encryptionInfo = mtx::accessors::file(*event); - qml_mtx_events::EventType eventType = toRoomEventType(event); + qml_mtx_events::EventType eventType = toRoomEventType(*event); QString dialogTitle; if (eventType == qml_mtx_events::EventType::ImageMessage) { @@ -1698,18 +1271,15 @@ TimelineModel::saveMedia(QString eventId) const void TimelineModel::cacheMedia(QString eventId) { - mtx::events::collections::TimelineEvents event = events.value(eventId); - - if (auto e = - std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(&event)) { - event = decryptEvent(*e).event; - } + mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), ""); + if (!event) + return; - QString mxcUrl = QString::fromStdString(mtx::accessors::url(event)); - QString originalFilename = QString::fromStdString(mtx::accessors::filename(event)); - QString mimeType = QString::fromStdString(mtx::accessors::mimetype(event)); + QString mxcUrl = QString::fromStdString(mtx::accessors::url(*event)); + QString originalFilename = QString::fromStdString(mtx::accessors::filename(*event)); + QString mimeType = QString::fromStdString(mtx::accessors::mimetype(*event)); - auto encryptionInfo = mtx::accessors::file(event); + auto encryptionInfo = mtx::accessors::file(*event); // If the message is a link to a non mxcUrl, don't download it if (!mxcUrl.startsWith("mxc://")) { @@ -1830,11 +1400,11 @@ TimelineModel::formatTypingUsers(const std::vector<QString> &users, QColor bg) QString TimelineModel::formatJoinRuleEvent(QString id) { - if (!events.contains(id)) + mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), ""); + if (!e) return ""; - auto event = - std::get_if<mtx::events::StateEvent<mtx::events::state::JoinRules>>(&events[id]); + auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::JoinRules>>(e); if (!event) return ""; @@ -1855,11 +1425,11 @@ TimelineModel::formatJoinRuleEvent(QString id) QString TimelineModel::formatGuestAccessEvent(QString id) { - if (!events.contains(id)) + mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), ""); + if (!e) return ""; - auto event = - std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(&events[id]); + auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(e); if (!event) return ""; @@ -1879,11 +1449,11 @@ TimelineModel::formatGuestAccessEvent(QString id) QString TimelineModel::formatHistoryVisibilityEvent(QString id) { - if (!events.contains(id)) + mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), ""); + if (!e) return ""; - auto event = - std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(&events[id]); + auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(e); if (!event) return ""; @@ -1913,11 +1483,11 @@ TimelineModel::formatHistoryVisibilityEvent(QString id) QString TimelineModel::formatPowerLevelEvent(QString id) { - if (!events.contains(id)) + mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), ""); + if (!e) return ""; - auto event = - std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(&events[id]); + auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(e); if (!event) return ""; @@ -1931,37 +1501,22 @@ TimelineModel::formatPowerLevelEvent(QString id) QString TimelineModel::formatMemberEvent(QString id) { - if (!events.contains(id)) + mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), ""); + if (!e) return ""; - auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(&events[id]); + auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e); if (!event) return ""; mtx::events::StateEvent<mtx::events::state::Member> *prevEvent = nullptr; - QString prevEventId = QString::fromStdString(event->unsigned_data.replaces_state); - if (!prevEventId.isEmpty()) { - if (!events.contains(prevEventId)) { - http::client()->get_event( - this->room_id_.toStdString(), - event->unsigned_data.replaces_state, - [this, id, prevEventId]( - const mtx::events::collections::TimelineEvents &timeline, - mtx::http::RequestErr err) { - if (err) { - nhlog::net()->error( - "Failed to retrieve event with id {}, which was " - "requested to show the membership for event {}", - prevEventId.toStdString(), - id.toStdString()); - return; - } - emit eventFetched(id, timeline); - }); - } else { + 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>>( - &events[prevEventId]); + tempPrevEvent); } } diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index 95584d36..156606e6 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -9,7 +9,7 @@ #include <mtxclient/http/errors.hpp> #include "CacheCryptoStructs.h" -#include "ReactionsModel.h" +#include "EventStore.h" namespace mtx::http { using RequestErr = const std::optional<mtx::http::ClientError> &; @@ -42,6 +42,8 @@ enum EventType CallAnswer, /// m.call.hangup CallHangUp, + /// m.call.candidates + CallCandidates, /// m.room.canonical_alias CanonicalAlias, /// m.room.create @@ -177,7 +179,7 @@ public: QHash<int, QByteArray> roleNames() const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QVariant data(const QString &id, int role) const; + QVariant data(const mtx::events::collections::TimelineEvents &event, int role) const; bool canFetchMore(const QModelIndex &) const override; void fetchMore(const QModelIndex &) override; @@ -204,6 +206,15 @@ public: Q_INVOKABLE void cacheMedia(QString eventId); Q_INVOKABLE bool saveMedia(QString eventId) const; + std::vector<::Reaction> reactions(const std::string &event_id) + { + auto list = events.reactions(event_id); + std::vector<::Reaction> vec; + for (const auto &r : list) + vec.push_back(r.value<Reaction>()); + return vec; + } + void updateLastMessage(); void addEvents(const mtx::responses::Timeline &events); template<class T> @@ -214,7 +225,7 @@ public slots: void setCurrentIndex(int index); int currentIndex() const { return idToIndex(currentId); } void markEventsAsRead(const std::vector<QString> &event_ids); - QVariantMap getDump(QString eventId) const; + QVariantMap getDump(QString eventId, QString relatedTo) const; void updateTypingUsers(const std::vector<QString> &users) { if (this->typingUsers_ != users) { @@ -240,36 +251,26 @@ public slots: } } void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; } + void clearTimeline() { events.clearTimeline(); } private slots: - // Add old events at the top of the timeline. - void addBackwardsEvents(const mtx::responses::Messages &msgs); - void processOnePendingMessage(); void addPendingMessage(mtx::events::collections::TimelineEvents event); signals: - void oldMessagesRetrieved(const mtx::responses::Messages &res); - void messageFailed(QString txn_id); - void messageSent(QString txn_id, QString event_id); void currentIndexChanged(int index); void redactionFailed(QString id); void eventRedacted(QString id); - void nextPendingMessage(); - void newMessageToSend(mtx::events::collections::TimelineEvents event); void mediaCached(QString mxcUrl, QString cacheUrl); void newEncryptedImage(mtx::crypto::EncryptedFile encryptionInfo); - void eventFetched(QString requestingEvent, mtx::events::collections::TimelineEvents event); void typingUsersChanged(std::vector<QString> users); void replyChanged(QString reply); void paginationInProgressChanged(const bool); void newCallEvent(const mtx::events::collections::TimelineEvents &event); + void newMessageToSend(mtx::events::collections::TimelineEvents event); + void addPendingMessageToStore(mtx::events::collections::TimelineEvents event); + private: - DecryptionResult decryptEvent( - const mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> &e) const; - std::vector<QString> internalAddEvents( - const std::vector<mtx::events::collections::TimelineEvents> &timeline, - bool emitCallEvents); void sendEncryptedMessageEvent(const std::string &txn_id, nlohmann::json content, mtx::events::EventType); @@ -283,16 +284,12 @@ private: void setPaginationInProgress(const bool paginationInProgress); - QHash<QString, mtx::events::collections::TimelineEvents> events; QSet<QString> read; - QList<QString> pending; - std::vector<QString> eventOrder; - std::map<QString, ReactionsModel> reactions; + + mutable EventStore events; QString room_id_; - QString prev_batch_token_; - bool isInitialSync = true; bool decryptDescription = true; bool m_paginationInProgress = false; diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index 5d0b54c3..466c3cee 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -340,36 +340,39 @@ TimelineViewManager::queueEmoteMessage(const QString &msg) } void -TimelineViewManager::reactToMessage(const QString &roomId, - const QString &reactedEvent, - const QString &reactionKey, - const QString &selfReactedEvent) +TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey) { + if (!timeline_) + return; + + auto reactions = timeline_->reactions(reactedEvent.toStdString()); + + QString selfReactedEvent; + for (const auto &reaction : reactions) { + if (reactionKey == reaction.key_) { + selfReactedEvent = reaction.selfReactedEvent_; + break; + } + } + + if (selfReactedEvent.startsWith("m")) + return; + // If selfReactedEvent is empty, that means we haven't previously reacted if (selfReactedEvent.isEmpty()) { - queueReactionMessage(roomId, reactedEvent, reactionKey); + mtx::events::msg::Reaction reaction; + reaction.relates_to.rel_type = mtx::common::RelationType::Annotation; + reaction.relates_to.event_id = reactedEvent.toStdString(); + reaction.relates_to.key = reactionKey.toStdString(); + + timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction); // Otherwise, we have previously reacted and the reaction should be redacted } else { - auto model = models.value(roomId); - model->redactEvent(selfReactedEvent); + timeline_->redactEvent(selfReactedEvent); } } void -TimelineViewManager::queueReactionMessage(const QString &roomId, - const QString &reactedEvent, - const QString &reactionKey) -{ - mtx::events::msg::Reaction reaction; - reaction.relates_to.rel_type = mtx::common::RelationType::Annotation; - reaction.relates_to.event_id = reactedEvent.toStdString(); - reaction.relates_to.key = reactionKey.toStdString(); - - auto model = models.value(roomId); - model->sendMessageEvent(reaction, mtx::events::EventType::RoomMessage); -} - -void TimelineViewManager::queueImageMessage(const QString &roomid, const QString &filename, const std::optional<mtx::crypto::EncryptedFile> &file, @@ -384,10 +387,13 @@ TimelineViewManager::queueImageMessage(const QString &roomid, image.info.size = dsize; image.info.blurhash = blurhash.toStdString(); image.body = filename.toStdString(); - image.url = url.toStdString(); image.info.h = dimensions.height(); image.info.w = dimensions.width(); - image.file = file; + + if (file) + image.file = file; + else + image.url = url.toStdString(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { @@ -411,8 +417,11 @@ TimelineViewManager::queueFileMessage( file.info.mimetype = mime.toStdString(); file.info.size = dsize; file.body = filename.toStdString(); - file.url = url.toStdString(); - file.file = encryptedFile; + + if (encryptedFile) + file.file = encryptedFile; + else + file.url = url.toStdString(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { @@ -436,7 +445,11 @@ TimelineViewManager::queueAudioMessage(const QString &roomid, audio.info.size = dsize; audio.body = filename.toStdString(); audio.url = url.toStdString(); - audio.file = file; + + if (file) + audio.file = file; + else + audio.url = url.toStdString(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { @@ -459,8 +472,11 @@ TimelineViewManager::queueVideoMessage(const QString &roomid, video.info.mimetype = mime.toStdString(); video.info.size = dsize; video.body = filename.toStdString(); - video.url = url.toStdString(); - video.file = file; + + if (file) + video.file = file; + else + video.url = url.toStdString(); auto model = models.value(roomid); if (!model->reply().isEmpty()) { diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index 902dc047..ea6d1743 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -66,13 +66,7 @@ public slots: void setHistoryView(const QString &room_id); void updateColorPalette(); - void queueReactionMessage(const QString &roomId, - const QString &reactedEvent, - const QString &reaction); - void reactToMessage(const QString &roomId, - const QString &reactedEvent, - const QString &reactionKey, - const QString &selfReactedEvent); + void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey); void queueTextMessage(const QString &msg); void queueEmoteMessage(const QString &msg); void queueImageMessage(const QString &roomid, @@ -108,6 +102,12 @@ public slots: void updateEncryptedDescriptions(); + void clearCurrentRoomTimeline() + { + if (timeline_) + timeline_->clearTimeline(); + } + private: #ifdef USE_QUICK_VIEW QQuickView *view; |