diff --git a/CMakeLists.txt b/CMakeLists.txt
index de617dc3..46d83f67 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -225,6 +225,7 @@ configure_file(cmake/nheko.h config/nheko.h)
#
set(SRC_FILES
# Dialogs
+ src/dialogs/AcceptCall.cpp
src/dialogs/CreateRoom.cpp
src/dialogs/FallbackAuth.cpp
src/dialogs/ImageOverlay.cpp
@@ -233,6 +234,7 @@ set(SRC_FILES
src/dialogs/LeaveRoom.cpp
src/dialogs/Logout.cpp
src/dialogs/MemberList.cpp
+ src/dialogs/PlaceCall.cpp
src/dialogs/PreviewUploadOverlay.cpp
src/dialogs/ReCaptcha.cpp
src/dialogs/ReadReceipts.cpp
@@ -277,9 +279,11 @@ set(SRC_FILES
src/ui/ThemeManager.cpp
src/ui/UserProfile.cpp
+ src/ActiveCallBar.cpp
src/AvatarProvider.cpp
src/BlurhashProvider.cpp
src/Cache.cpp
+ src/CallManager.cpp
src/ChatPage.cpp
src/ColorImageProvider.cpp
src/CommunitiesList.cpp
@@ -306,6 +310,7 @@ set(SRC_FILES
src/UserInfoWidget.cpp
src/UserSettingsPage.cpp
src/Utils.cpp
+ src/WebRTCSession.cpp
src/WelcomePage.cpp
src/popups/PopupItem.cpp
src/popups/SuggestionsPopup.cpp
@@ -423,6 +428,9 @@ else()
find_package(Tweeny REQUIRED)
endif()
+include(FindPkgConfig)
+pkg_check_modules(GSTREAMER IMPORTED_TARGET gstreamer-sdp-1.0>=1.14 gstreamer-webrtc-1.0>=1.14)
+
# single instance functionality
set(QAPPLICATION_CLASS QApplication CACHE STRING "Inheritance class for SingleApplication")
add_subdirectory(third_party/SingleApplication-3.1.3.1/)
@@ -431,6 +439,7 @@ feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAG
qt5_wrap_cpp(MOC_HEADERS
# Dialogs
+ src/dialogs/AcceptCall.h
src/dialogs/CreateRoom.h
src/dialogs/FallbackAuth.h
src/dialogs/ImageOverlay.h
@@ -439,6 +448,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/dialogs/LeaveRoom.h
src/dialogs/Logout.h
src/dialogs/MemberList.h
+ src/dialogs/PlaceCall.h
src/dialogs/PreviewUploadOverlay.h
src/dialogs/RawMessage.h
src/dialogs/ReCaptcha.h
@@ -483,9 +493,11 @@ qt5_wrap_cpp(MOC_HEADERS
src/notifications/Manager.h
+ src/ActiveCallBar.h
src/AvatarProvider.h
src/BlurhashProvider.h
src/Cache_p.h
+ src/CallManager.h
src/ChatPage.h
src/CommunitiesList.h
src/CommunitiesListItem.h
@@ -506,6 +518,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/TrayIcon.h
src/UserInfoWidget.h
src/UserSettingsPage.h
+ src/WebRTCSession.h
src/WelcomePage.h
src/popups/PopupItem.h
src/popups/SuggestionsPopup.h
@@ -594,6 +607,11 @@ target_precompile_headers(nheko
)
endif()
+if (TARGET PkgConfig::GSTREAMER)
+ target_link_libraries(nheko PRIVATE PkgConfig::GSTREAMER)
+ target_compile_definitions(nheko PRIVATE GSTREAMER_AVAILABLE)
+endif()
+
if(MSVC)
target_link_libraries(nheko PRIVATE ntdll)
endif()
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/resources/icons/ui/end-call.png b/resources/icons/ui/end-call.png
new file mode 100644
index 00000000..6cbb983e
--- /dev/null
+++ b/resources/icons/ui/end-call.png
Binary files differdiff --git a/resources/icons/ui/microphone-mute.png b/resources/icons/ui/microphone-mute.png
new file mode 100644
index 00000000..0042fbe2
--- /dev/null
+++ b/resources/icons/ui/microphone-mute.png
Binary files differdiff --git a/resources/icons/ui/microphone-unmute.png b/resources/icons/ui/microphone-unmute.png
new file mode 100644
index 00000000..27999c70
--- /dev/null
+++ b/resources/icons/ui/microphone-unmute.png
Binary files differdiff --git a/resources/icons/ui/place-call.png b/resources/icons/ui/place-call.png
new file mode 100644
index 00000000..a820cf3f
--- /dev/null
+++ b/resources/icons/ui/place-call.png
Binary files differdiff --git a/resources/langs/nheko_en.ts b/resources/langs/nheko_en.ts
index db24f1fe..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"/>
@@ -404,6 +404,21 @@ Example: https://server.my:8787</translation>
<source>%1 created and configured room: %2</source>
<translation>%1 created and configured room: %2</translation>
</message>
+ <message>
+ <location line="+6"/>
+ <source>%1 placed a %2 call.</source>
+ <translation>%1 placed a %2 call.</translation>
+ </message>
+ <message>
+ <location line="+6"/>
+ <source>%1 answered the call.</source>
+ <translation>%1 answered the call.</translation>
+ </message>
+ <message>
+ <location line="+6"/>
+ <source>%1 ended the call.</source>
+ <translation>%1 ended the call.</translation>
+ </message>
</context>
<context>
<name>Placeholder</name>
@@ -1796,6 +1811,36 @@ Media size: %2
<source>%1 sent an encrypted message</source>
<translation>%1 sent an encrypted message</translation>
</message>
+ <message>
+ <location line="+5"/>
+ <source>You placed a call</source>
+ <translation>You placed a call</translation>
+ </message>
+ <message>
+ <location line="+3"/>
+ <source>%1 placed a call</source>
+ <translation>%1 placed a call</translation>
+ </message>
+ <message>
+ <location line="+5"/>
+ <source>You answered a call</source>
+ <translation>You answered a call</translation>
+ </message>
+ <message>
+ <location line="+3"/>
+ <source>%1 answered a call</source>
+ <translation>%1 answered a call</translation>
+ </message>
+ <message>
+ <location line="+5"/>
+ <source>You ended a call</source>
+ <translation>You ended a call</translation>
+ </message>
+ <message>
+ <location line="+3"/>
+ <source>%1 ended a call</source>
+ <translation>%1 ended a call</translation>
+ </message>
</context>
<context>
<name>popups::UserMentions</name>
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/langs/nheko_si.ts b/resources/langs/nheko_si.ts
new file mode 100644
index 00000000..2f405ca2
--- /dev/null
+++ b/resources/langs/nheko_si.ts
@@ -0,0 +1,1604 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="si">
+<context>
+ <name>Cache</name>
+ <message>
+ <location filename="../../src/Cache.cpp" line="+1341"/>
+ <source>You joined this room.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>ChatPage</name>
+ <message>
+ <location filename="../../src/ChatPage.cpp" line="+226"/>
+ <source>Failed to invite user: %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+4"/>
+ <location line="+892"/>
+ <source>Invited user: %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="-463"/>
+ <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="+428"/>
+ <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="-817"/>
+ <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="+148"/>
+ <source>Failed to setup encryption keys. Server response: %1 %2. Please try again later.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+51"/>
+ <location line="+219"/>
+ <source>Please try to login again: %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="-154"/>
+ <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"/>
+ <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="+60"/>
+ <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>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="+17"/>
+ <source>Remove</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>LoginPage</name>
+ <message>
+ <location filename="../../src/LoginPage.cpp" line="+89"/>
+ <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 random string is used for privacy purposes.</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="+185"/>
+ <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="+87"/>
+ <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="+939"/>
+ <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="+7"/>
+ <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="+13"/>
+ <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="+3"/>
+ <source>New Tag</source>
+ <comment>Tag name prompt title</comment>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+0"/>
+ <source>Tag:</source>
+ <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="+38"/>
+ <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="+459"/>
+ <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="+84"/>
+ <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="+853"/>
+ <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="+455"/>
+ <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="-610"/>
+ <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="+660"/>
+ <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>
+ </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="+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="-1238"/>
+ <source>You joined this room.</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1240"/>
+ <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="+92"/>
+ <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>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="+257"/>
+ <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="+88"/>
+ <source>Logout</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>UserSettingsPage</name>
+ <message>
+ <location filename="../../src/UserSettingsPage.cpp" line="+339"/>
+ <source>Minimize to tray</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Start in tray</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+3"/>
+ <source>Group's sidebar</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="-1"/>
+ <source>Circular Avatars</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+2"/>
+ <source>Decrypt messages in sidebar</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Show buttons in timeline</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Typing notifications</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Sort rooms by unreads</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+2"/>
+ <source>Read receipts</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Send messages as Markdown</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Desktop notifications</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+1"/>
+ <source>Highlight message on hover</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+5"/>
+ <source>Scale factor</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="-60"/>
+ <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="-64"/>
+ <source>GENERAL</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+26"/>
+ <source>INTERFACE</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+109"/>
+ <source>Emoji Font Family</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+163"/>
+ <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="+141"/>
+ <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="+41"/>
+ <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="+117"/>
+ <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="-44"/>
+ <source>Today %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location line="+2"/>
+ <source>Yesterday %1</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>dialogs::RoomSettings</name>
+ <message>
+ <location filename="../../src/dialogs/RoomSettings.cpp" line="+113"/>
+ <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>
+ </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="+100"/>
+ <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/media/README.txt b/resources/media/README.txt
new file mode 100644
index 00000000..ce1e5933
--- /dev/null
+++ b/resources/media/README.txt
@@ -0,0 +1,5 @@
+The below media files were obtained from https://github.com/matrix-org/matrix-react-sdk/tree/develop/res/media
+
+callend.ogg
+ringback.ogg
+ring.ogg
diff --git a/resources/media/callend.ogg b/resources/media/callend.ogg
new file mode 100644
index 00000000..927ce1f6
--- /dev/null
+++ b/resources/media/callend.ogg
Binary files differdiff --git a/resources/media/ring.ogg b/resources/media/ring.ogg
new file mode 100644
index 00000000..708213bf
--- /dev/null
+++ b/resources/media/ring.ogg
Binary files differdiff --git a/resources/media/ringback.ogg b/resources/media/ringback.ogg
new file mode 100644
index 00000000..7dbfdcd0
--- /dev/null
+++ b/resources/media/ringback.ogg
Binary files differdiff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml
index d0910045..bbbb80cf 100644
--- a/resources/qml/MatrixText.qml
+++ b/resources/qml/MatrixText.qml
@@ -7,7 +7,8 @@ TextEdit {
textFormat: TextEdit.RichText
readOnly: true
wrapMode: Text.Wrap
- selectByMouse: ma.containsMouse // try to make scrollable by finger but selectable by mouse
+ selectByMouse: true
+ activeFocusOnPress: false
color: colors.text
onLinkActivated: {
@@ -18,14 +19,13 @@ TextEdit {
TimelineManager.setHistoryView(match[1])
chat.positionViewAtIndex(chat.model.idToIndex(match[2]), ListView.Contain)
}
- else Qt.openUrlExternally(link)
+ else timelineManager.openLink(link)
}
MouseArea
{
id: ma
anchors.fill: parent
propagateComposedEvents: true
- hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml
index b464b76c..2979908e 100644
--- a/resources/qml/TimelineRow.qml
+++ b/resources/qml/TimelineRow.qml
@@ -130,6 +130,7 @@ Item {
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 86b78a1e..f2390b18 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -21,7 +21,6 @@ Page {
property real highlightHue: colors.highlight.hslHue
property real highlightSat: colors.highlight.hslSaturation
property real highlightLight: colors.highlight.hslLightness
- property variant userProfile
palette: colors
@@ -287,6 +286,7 @@ Page {
width: contentWidth * 1.2
horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
background: Rectangle {
radius: parent.height / 2
color: colors.base
diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml
index c556a978..ff025730 100644
--- a/resources/qml/delegates/MessageDelegate.qml
+++ b/resources/qml/delegates/MessageDelegate.qml
@@ -97,6 +97,30 @@ Item {
}
}
DelegateChoice {
+ roleValue: MtxEvent.CallInvite
+ NoticeMessage {
+ text: qsTr("%1 placed a %2 call.").arg(model.data.userName).arg(model.data.callType)
+ }
+ }
+ DelegateChoice {
+ roleValue: MtxEvent.CallAnswer
+ NoticeMessage {
+ text: qsTr("%1 answered the call.").arg(model.data.userName)
+ }
+ }
+ DelegateChoice {
+ roleValue: MtxEvent.CallHangUp
+ NoticeMessage {
+ text: qsTr("%1 ended the call.").arg(model.data.userName)
+ }
+ }
+ 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/res.qrc b/resources/res.qrc
index cb724dd3..e8f1f7be 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -70,6 +70,11 @@
<file>icons/ui/mail-reply.png</file>
+ <file>icons/ui/place-call.png</file>
+ <file>icons/ui/end-call.png</file>
+ <file>icons/ui/microphone-mute.png</file>
+ <file>icons/ui/microphone-unmute.png</file>
+
<file>icons/emoji-categories/people.png</file>
<file>icons/emoji-categories/people@2x.png</file>
<file>icons/emoji-categories/nature.png</file>
@@ -138,4 +143,9 @@
<file>qml/delegates/Reply.qml</file>
<file>qml/device-verification/DeviceVerification.qml</file>
</qresource>
+ <qresource prefix="/media">
+ <file>media/ring.ogg</file>
+ <file>media/ringback.ogg</file>
+ <file>media/callend.ogg</file>
+ </qresource>
</RCC>
diff --git a/src/ActiveCallBar.cpp b/src/ActiveCallBar.cpp
new file mode 100644
index 00000000..c0d2c13a
--- /dev/null
+++ b/src/ActiveCallBar.cpp
@@ -0,0 +1,160 @@
+#include <cstdio>
+
+#include <QDateTime>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QLabel>
+#include <QString>
+#include <QTimer>
+
+#include "ActiveCallBar.h"
+#include "ChatPage.h"
+#include "Utils.h"
+#include "WebRTCSession.h"
+#include "ui/Avatar.h"
+#include "ui/FlatButton.h"
+
+ActiveCallBar::ActiveCallBar(QWidget *parent)
+ : QWidget(parent)
+{
+ setAutoFillBackground(true);
+ auto p = palette();
+ p.setColor(backgroundRole(), QColor(46, 204, 113));
+ setPalette(p);
+
+ QFont f;
+ f.setPointSizeF(f.pointSizeF());
+
+ const int fontHeight = QFontMetrics(f).height();
+ const int widgetMargin = fontHeight / 3;
+ const int contentHeight = fontHeight * 3;
+
+ setFixedHeight(contentHeight + widgetMargin);
+
+ layout_ = new QHBoxLayout(this);
+ layout_->setSpacing(widgetMargin);
+ layout_->setContentsMargins(2 * widgetMargin, widgetMargin, 2 * widgetMargin, widgetMargin);
+
+ QFont labelFont;
+ labelFont.setPointSizeF(labelFont.pointSizeF() * 1.1);
+ labelFont.setWeight(QFont::Medium);
+
+ avatar_ = new Avatar(this, QFontMetrics(f).height() * 2.5);
+
+ callPartyLabel_ = new QLabel(this);
+ callPartyLabel_->setFont(labelFont);
+
+ stateLabel_ = new QLabel(this);
+ stateLabel_->setFont(labelFont);
+
+ durationLabel_ = new QLabel(this);
+ durationLabel_->setFont(labelFont);
+ durationLabel_->hide();
+
+ muteBtn_ = new FlatButton(this);
+ setMuteIcon(false);
+ muteBtn_->setFixedSize(buttonSize_, buttonSize_);
+ muteBtn_->setCornerRadius(buttonSize_ / 2);
+ connect(muteBtn_, &FlatButton::clicked, this, [this]() {
+ if (WebRTCSession::instance().toggleMuteAudioSrc(muted_))
+ setMuteIcon(muted_);
+ });
+
+ layout_->addWidget(avatar_, 0, Qt::AlignLeft);
+ layout_->addWidget(callPartyLabel_, 0, Qt::AlignLeft);
+ layout_->addWidget(stateLabel_, 0, Qt::AlignLeft);
+ layout_->addWidget(durationLabel_, 0, Qt::AlignLeft);
+ layout_->addStretch();
+ layout_->addWidget(muteBtn_, 0, Qt::AlignCenter);
+ layout_->addSpacing(18);
+
+ timer_ = new QTimer(this);
+ connect(timer_, &QTimer::timeout, this, [this]() {
+ auto seconds = QDateTime::currentSecsSinceEpoch() - callStartTime_;
+ int s = seconds % 60;
+ int m = (seconds / 60) % 60;
+ int h = seconds / 3600;
+ char buf[12];
+ if (h)
+ snprintf(buf, sizeof(buf), "%.2d:%.2d:%.2d", h, m, s);
+ else
+ snprintf(buf, sizeof(buf), "%.2d:%.2d", m, s);
+ durationLabel_->setText(buf);
+ });
+
+ connect(
+ &WebRTCSession::instance(), &WebRTCSession::stateChanged, this, &ActiveCallBar::update);
+}
+
+void
+ActiveCallBar::setMuteIcon(bool muted)
+{
+ QIcon icon;
+ if (muted) {
+ muteBtn_->setToolTip("Unmute Mic");
+ icon.addFile(":/icons/icons/ui/microphone-unmute.png");
+ } else {
+ muteBtn_->setToolTip("Mute Mic");
+ icon.addFile(":/icons/icons/ui/microphone-mute.png");
+ }
+ muteBtn_->setIcon(icon);
+ muteBtn_->setIconSize(QSize(buttonSize_, buttonSize_));
+}
+
+void
+ActiveCallBar::setCallParty(const QString &userid,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl)
+{
+ callPartyLabel_->setText(" " + (displayName.isEmpty() ? userid : displayName) + " ");
+
+ if (!avatarUrl.isEmpty())
+ avatar_->setImage(avatarUrl);
+ else
+ avatar_->setLetter(utils::firstChar(roomName));
+}
+
+void
+ActiveCallBar::update(WebRTCSession::State state)
+{
+ switch (state) {
+ case WebRTCSession::State::INITIATING:
+ show();
+ stateLabel_->setText("Initiating call...");
+ break;
+ case WebRTCSession::State::INITIATED:
+ show();
+ stateLabel_->setText("Call initiated...");
+ break;
+ case WebRTCSession::State::OFFERSENT:
+ show();
+ stateLabel_->setText("Calling...");
+ break;
+ case WebRTCSession::State::CONNECTING:
+ show();
+ stateLabel_->setText("Connecting...");
+ break;
+ case WebRTCSession::State::CONNECTED:
+ show();
+ callStartTime_ = QDateTime::currentSecsSinceEpoch();
+ timer_->start(1000);
+ stateLabel_->setPixmap(
+ QIcon(":/icons/icons/ui/place-call.png").pixmap(QSize(buttonSize_, buttonSize_)));
+ durationLabel_->setText("00:00");
+ durationLabel_->show();
+ break;
+ case WebRTCSession::State::ICEFAILED:
+ case WebRTCSession::State::DISCONNECTED:
+ hide();
+ timer_->stop();
+ callPartyLabel_->setText(QString());
+ stateLabel_->setText(QString());
+ durationLabel_->setText(QString());
+ durationLabel_->hide();
+ setMuteIcon(false);
+ break;
+ default:
+ break;
+ }
+}
diff --git a/src/ActiveCallBar.h b/src/ActiveCallBar.h
new file mode 100644
index 00000000..1e940227
--- /dev/null
+++ b/src/ActiveCallBar.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <QWidget>
+
+#include "WebRTCSession.h"
+
+class QHBoxLayout;
+class QLabel;
+class QTimer;
+class Avatar;
+class FlatButton;
+
+class ActiveCallBar : public QWidget
+{
+ Q_OBJECT
+
+public:
+ ActiveCallBar(QWidget *parent = nullptr);
+
+public slots:
+ void update(WebRTCSession::State);
+ void setCallParty(const QString &userid,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl);
+
+private:
+ QHBoxLayout *layout_ = nullptr;
+ Avatar *avatar_ = nullptr;
+ QLabel *callPartyLabel_ = nullptr;
+ QLabel *stateLabel_ = nullptr;
+ QLabel *durationLabel_ = nullptr;
+ FlatButton *muteBtn_ = nullptr;
+ int buttonSize_ = 22;
+ bool muted_ = false;
+ qint64 callStartTime_ = 0;
+ QTimer *timer_ = nullptr;
+
+ void setMuteIcon(bool muted);
+};
diff --git a/src/Cache.cpp b/src/Cache.cpp
index cff0029e..5302218a 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -35,6 +35,7 @@
#include "EventAccessors.h"
#include "Logging.h"
#include "MatrixClient.h"
+#include "Olm.h"
#include "Utils.h"
//! Should be changed when a breaking change occurs in the cache format.
@@ -95,6 +96,33 @@ namespace {
std::unique_ptr<Cache> instance_ = nullptr;
}
+static bool
+isHiddenEvent(mtx::events::collections::TimelineEvents e, const std::string &room_id)
+{
+ 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;
+
+ auto result = olm::decryptEvent(index, *encryptedEvent);
+ if (!result.error)
+ e = result.event.value();
+ }
+
+ 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)
: QObject{parent}
, env_{nullptr}
@@ -160,7 +188,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" +
@@ -776,6 +807,7 @@ Cache::runMigrations()
}},
};
+ nhlog::db()->info("Running migrations, this may take a while!");
for (const auto &[target_version, migration] : migrations) {
if (target_version > stored_version)
if (!migration()) {
@@ -783,6 +815,7 @@ Cache::runMigrations()
return false;
}
}
+ nhlog::db()->info("Migrations finished.");
setCurrentFormat();
return true;
@@ -1608,7 +1641,8 @@ Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id)
}
if (!(obj["type"] == "m.room.message" || obj["type"] == "m.sticker" ||
- obj["type"] == "m.room.encrypted"))
+ 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 te;
@@ -2326,6 +2360,11 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
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;
+
lmdb::val txn_order;
if (!txn_id.empty() &&
lmdb::dbi_get(txn, evToOrderDb, lmdb::val(txn_id), txn_order)) {
@@ -2339,7 +2378,7 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
lmdb::dbi_del(txn, msg2orderDb, lmdb::val(txn_id));
}
- lmdb::dbi_put(txn, orderDb, txn_order, event_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));
@@ -2411,10 +2450,6 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
++index;
- json orderEntry = json::object();
- orderEntry["event_id"] = event_id_val;
- if (first && !res.prev_batch.empty())
- orderEntry["prev_batch"] = res.prev_batch;
first = false;
nhlog::db()->debug("saving '{}'", orderEntry.dump());
@@ -2426,7 +2461,7 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
lmdb::dbi_put(txn, evToOrderDb, event_id, lmdb::val(&index, sizeof(index)));
// TODO(Nico): Allow blacklisting more event types in UI
- if (event["type"] != "m.reaction" && event["type"] != "m.dummy") {
+ if (!isHiddenEvent(e, room_id)) {
++msgIndex;
lmdb::cursor_put(msgCursor.handle(),
lmdb::val(&msgIndex, sizeof(msgIndex)),
@@ -2462,6 +2497,7 @@ Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Message
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);
@@ -2505,9 +2541,10 @@ Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Message
lmdb::dbi_put(
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 (event["type"] != "m.reaction" && event["type"] != "m.dummy") {
+ if (!isHiddenEvent(e, room_id)) {
--msgIndex;
lmdb::dbi_put(
txn, order2msgDb, lmdb::val(&msgIndex, sizeof(msgIndex)), event_id);
@@ -2538,6 +2575,94 @@ Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Message
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
Cache::getTimelineMentionsForRoom(lmdb::txn &txn, const std::string &room_id)
{
@@ -2676,11 +2801,13 @@ Cache::deleteOldMessages()
auto room_ids = getRoomIds(txn);
for (const auto &room_id : room_ids) {
- auto orderDb = getEventOrderDb(txn, room_id);
- auto o2m = getOrderToMessageDb(txn, room_id);
- auto m2o = getMessageToOrderDb(txn, room_id);
- auto eventsDb = getEventsDb(txn, room_id);
- auto cursor = lmdb::cursor::open(txn, orderDb);
+ 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)) {
@@ -2700,14 +2827,17 @@ Cache::deleteOldMessages()
bool start = true;
while (cursor.get(indexVal, val, start ? MDB_FIRST : MDB_NEXT) &&
- message_count-- < MAX_RESTORED_MESSAGES) {
+ message_count-- > MAX_RESTORED_MESSAGES) {
start = false;
auto obj = json::parse(std::string_view(val.data(), val.size()));
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, m2o, event_id, order);
if (exists) {
diff --git a/src/Cache_p.h b/src/Cache_p.h
index 174090a9..7d7b70e6 100644
--- a/src/Cache_p.h
+++ b/src/Cache_p.h
@@ -218,6 +218,9 @@ public:
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;
diff --git a/src/CallManager.cpp b/src/CallManager.cpp
new file mode 100644
index 00000000..7a8d2ca7
--- /dev/null
+++ b/src/CallManager.cpp
@@ -0,0 +1,458 @@
+#include <algorithm>
+#include <cctype>
+#include <chrono>
+#include <cstdint>
+
+#include <QMediaPlaylist>
+#include <QUrl>
+
+#include "Cache.h"
+#include "CallManager.h"
+#include "ChatPage.h"
+#include "Logging.h"
+#include "MainWindow.h"
+#include "MatrixClient.h"
+#include "UserSettingsPage.h"
+#include "WebRTCSession.h"
+#include "dialogs/AcceptCall.h"
+
+#include "mtx/responses/turn_server.hpp"
+
+Q_DECLARE_METATYPE(std::vector<mtx::events::msg::CallCandidates::Candidate>)
+Q_DECLARE_METATYPE(mtx::events::msg::CallCandidates::Candidate)
+Q_DECLARE_METATYPE(mtx::responses::TurnServer)
+
+using namespace mtx::events;
+using namespace mtx::events::msg;
+
+// https://github.com/vector-im/riot-web/issues/10173
+#define STUN_SERVER "stun://turn.matrix.org:3478"
+
+namespace {
+std::vector<std::string>
+getTurnURIs(const mtx::responses::TurnServer &turnServer);
+}
+
+CallManager::CallManager(QSharedPointer<UserSettings> userSettings)
+ : QObject()
+ , session_(WebRTCSession::instance())
+ , turnServerTimer_(this)
+ , settings_(userSettings)
+{
+ qRegisterMetaType<std::vector<mtx::events::msg::CallCandidates::Candidate>>();
+ qRegisterMetaType<mtx::events::msg::CallCandidates::Candidate>();
+ qRegisterMetaType<mtx::responses::TurnServer>();
+
+ connect(
+ &session_,
+ &WebRTCSession::offerCreated,
+ this,
+ [this](const std::string &sdp, const std::vector<CallCandidates::Candidate> &candidates) {
+ nhlog::ui()->debug("WebRTC: call id: {} - sending offer", callid_);
+ emit newMessage(roomid_, CallInvite{callid_, sdp, 0, timeoutms_});
+ emit newMessage(roomid_, CallCandidates{callid_, candidates, 0});
+ QTimer::singleShot(timeoutms_, this, [this]() {
+ if (session_.state() == WebRTCSession::State::OFFERSENT) {
+ hangUp(CallHangUp::Reason::InviteTimeOut);
+ emit ChatPage::instance()->showNotification(
+ "The remote side failed to pick up.");
+ }
+ });
+ });
+
+ connect(
+ &session_,
+ &WebRTCSession::answerCreated,
+ this,
+ [this](const std::string &sdp, const std::vector<CallCandidates::Candidate> &candidates) {
+ nhlog::ui()->debug("WebRTC: call id: {} - sending answer", callid_);
+ emit newMessage(roomid_, CallAnswer{callid_, sdp, 0});
+ emit newMessage(roomid_, CallCandidates{callid_, candidates, 0});
+ });
+
+ connect(&session_,
+ &WebRTCSession::newICECandidate,
+ this,
+ [this](const CallCandidates::Candidate &candidate) {
+ nhlog::ui()->debug("WebRTC: call id: {} - sending ice candidate", callid_);
+ emit newMessage(roomid_, CallCandidates{callid_, {candidate}, 0});
+ });
+
+ connect(&turnServerTimer_, &QTimer::timeout, this, &CallManager::retrieveTurnServer);
+
+ connect(this,
+ &CallManager::turnServerRetrieved,
+ this,
+ [this](const mtx::responses::TurnServer &res) {
+ nhlog::net()->info("TURN server(s) retrieved from homeserver:");
+ nhlog::net()->info("username: {}", res.username);
+ nhlog::net()->info("ttl: {} seconds", res.ttl);
+ for (const auto &u : res.uris)
+ nhlog::net()->info("uri: {}", u);
+
+ // Request new credentials close to expiry
+ // See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
+ turnURIs_ = getTurnURIs(res);
+ uint32_t ttl = std::max(res.ttl, UINT32_C(3600));
+ if (res.ttl < 3600)
+ nhlog::net()->warn("Setting ttl to 1 hour");
+ turnServerTimer_.setInterval(ttl * 1000 * 0.9);
+ });
+
+ connect(&session_, &WebRTCSession::stateChanged, this, [this](WebRTCSession::State state) {
+ switch (state) {
+ case WebRTCSession::State::DISCONNECTED:
+ playRingtone("qrc:/media/media/callend.ogg", false);
+ clear();
+ break;
+ case WebRTCSession::State::ICEFAILED: {
+ QString error("Call connection failed.");
+ if (turnURIs_.empty())
+ error += " Your homeserver has no configured TURN server.";
+ emit ChatPage::instance()->showNotification(error);
+ hangUp(CallHangUp::Reason::ICEFailed);
+ break;
+ }
+ default:
+ break;
+ }
+ });
+
+ connect(&player_,
+ &QMediaPlayer::mediaStatusChanged,
+ this,
+ [this](QMediaPlayer::MediaStatus status) {
+ if (status == QMediaPlayer::LoadedMedia)
+ player_.play();
+ });
+}
+
+void
+CallManager::sendInvite(const QString &roomid)
+{
+ if (onActiveCall())
+ return;
+
+ auto roomInfo = cache::singleRoomInfo(roomid.toStdString());
+ if (roomInfo.member_count != 2) {
+ emit ChatPage::instance()->showNotification(
+ "Voice calls are limited to 1:1 rooms.");
+ return;
+ }
+
+ std::string errorMessage;
+ if (!session_.init(&errorMessage)) {
+ emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
+ return;
+ }
+
+ roomid_ = roomid;
+ session_.setStunServer(settings_->useStunServer() ? STUN_SERVER : "");
+ session_.setTurnServers(turnURIs_);
+
+ generateCallID();
+ nhlog::ui()->debug("WebRTC: call id: {} - creating invite", callid_);
+ std::vector<RoomMember> members(cache::getMembers(roomid.toStdString()));
+ const RoomMember &callee =
+ members.front().user_id == utils::localUser() ? members.back() : members.front();
+ emit newCallParty(callee.user_id,
+ callee.display_name,
+ QString::fromStdString(roomInfo.name),
+ QString::fromStdString(roomInfo.avatar_url));
+ playRingtone("qrc:/media/media/ringback.ogg", true);
+ if (!session_.createOffer()) {
+ emit ChatPage::instance()->showNotification("Problem setting up call.");
+ endCall();
+ }
+}
+
+namespace {
+std::string
+callHangUpReasonString(CallHangUp::Reason reason)
+{
+ switch (reason) {
+ case CallHangUp::Reason::ICEFailed:
+ return "ICE failed";
+ case CallHangUp::Reason::InviteTimeOut:
+ return "Invite time out";
+ default:
+ return "User";
+ }
+}
+}
+
+void
+CallManager::hangUp(CallHangUp::Reason reason)
+{
+ if (!callid_.empty()) {
+ nhlog::ui()->debug(
+ "WebRTC: call id: {} - hanging up ({})", callid_, callHangUpReasonString(reason));
+ emit newMessage(roomid_, CallHangUp{callid_, 0, reason});
+ endCall();
+ }
+}
+
+bool
+CallManager::onActiveCall()
+{
+ return session_.state() != WebRTCSession::State::DISCONNECTED;
+}
+
+void
+CallManager::syncEvent(const mtx::events::collections::TimelineEvents &event)
+{
+#ifdef GSTREAMER_AVAILABLE
+ if (handleEvent_<CallInvite>(event) || handleEvent_<CallCandidates>(event) ||
+ handleEvent_<CallAnswer>(event) || handleEvent_<CallHangUp>(event))
+ return;
+#else
+ (void)event;
+#endif
+}
+
+template<typename T>
+bool
+CallManager::handleEvent_(const mtx::events::collections::TimelineEvents &event)
+{
+ if (std::holds_alternative<RoomEvent<T>>(event)) {
+ handleEvent(std::get<RoomEvent<T>>(event));
+ return true;
+ }
+ return false;
+}
+
+void
+CallManager::handleEvent(const RoomEvent<CallInvite> &callInviteEvent)
+{
+ const char video[] = "m=video";
+ const std::string &sdp = callInviteEvent.content.sdp;
+ bool isVideo = std::search(sdp.cbegin(),
+ sdp.cend(),
+ std::cbegin(video),
+ std::cend(video) - 1,
+ [](unsigned char c1, unsigned char c2) {
+ return std::tolower(c1) == std::tolower(c2);
+ }) != sdp.cend();
+
+ nhlog::ui()->debug("WebRTC: call id: {} - incoming {} CallInvite from {}",
+ callInviteEvent.content.call_id,
+ (isVideo ? "video" : "voice"),
+ callInviteEvent.sender);
+
+ if (callInviteEvent.content.call_id.empty())
+ return;
+
+ auto roomInfo = cache::singleRoomInfo(callInviteEvent.room_id);
+ if (onActiveCall() || roomInfo.member_count != 2 || isVideo) {
+ emit newMessage(QString::fromStdString(callInviteEvent.room_id),
+ CallHangUp{callInviteEvent.content.call_id,
+ 0,
+ CallHangUp::Reason::InviteTimeOut});
+ return;
+ }
+
+ playRingtone("qrc:/media/media/ring.ogg", true);
+ roomid_ = QString::fromStdString(callInviteEvent.room_id);
+ callid_ = callInviteEvent.content.call_id;
+ remoteICECandidates_.clear();
+
+ std::vector<RoomMember> members(cache::getMembers(callInviteEvent.room_id));
+ const RoomMember &caller =
+ members.front().user_id == utils::localUser() ? members.back() : members.front();
+ emit newCallParty(caller.user_id,
+ caller.display_name,
+ QString::fromStdString(roomInfo.name),
+ QString::fromStdString(roomInfo.avatar_url));
+
+ auto dialog = new dialogs::AcceptCall(caller.user_id,
+ caller.display_name,
+ QString::fromStdString(roomInfo.name),
+ QString::fromStdString(roomInfo.avatar_url),
+ settings_,
+ MainWindow::instance());
+ connect(dialog, &dialogs::AcceptCall::accept, this, [this, callInviteEvent]() {
+ MainWindow::instance()->hideOverlay();
+ answerInvite(callInviteEvent.content);
+ });
+ connect(dialog, &dialogs::AcceptCall::reject, this, [this]() {
+ MainWindow::instance()->hideOverlay();
+ hangUp();
+ });
+ MainWindow::instance()->showSolidOverlayModal(dialog);
+}
+
+void
+CallManager::answerInvite(const CallInvite &invite)
+{
+ stopRingtone();
+ std::string errorMessage;
+ if (!session_.init(&errorMessage)) {
+ emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
+ hangUp();
+ return;
+ }
+
+ session_.setStunServer(settings_->useStunServer() ? STUN_SERVER : "");
+ session_.setTurnServers(turnURIs_);
+
+ if (!session_.acceptOffer(invite.sdp)) {
+ emit ChatPage::instance()->showNotification("Problem setting up call.");
+ hangUp();
+ return;
+ }
+ session_.acceptICECandidates(remoteICECandidates_);
+ remoteICECandidates_.clear();
+}
+
+void
+CallManager::handleEvent(const RoomEvent<CallCandidates> &callCandidatesEvent)
+{
+ if (callCandidatesEvent.sender == utils::localUser().toStdString())
+ return;
+
+ nhlog::ui()->debug("WebRTC: call id: {} - incoming CallCandidates from {}",
+ callCandidatesEvent.content.call_id,
+ callCandidatesEvent.sender);
+
+ if (callid_ == callCandidatesEvent.content.call_id) {
+ if (onActiveCall())
+ session_.acceptICECandidates(callCandidatesEvent.content.candidates);
+ else {
+ // CallInvite has been received and we're awaiting localUser to accept or
+ // reject the call
+ for (const auto &c : callCandidatesEvent.content.candidates)
+ remoteICECandidates_.push_back(c);
+ }
+ }
+}
+
+void
+CallManager::handleEvent(const RoomEvent<CallAnswer> &callAnswerEvent)
+{
+ nhlog::ui()->debug("WebRTC: call id: {} - incoming CallAnswer from {}",
+ callAnswerEvent.content.call_id,
+ callAnswerEvent.sender);
+
+ if (!onActiveCall() && callAnswerEvent.sender == utils::localUser().toStdString() &&
+ callid_ == callAnswerEvent.content.call_id) {
+ emit ChatPage::instance()->showNotification("Call answered on another device.");
+ stopRingtone();
+ MainWindow::instance()->hideOverlay();
+ return;
+ }
+
+ if (onActiveCall() && callid_ == callAnswerEvent.content.call_id) {
+ stopRingtone();
+ if (!session_.acceptAnswer(callAnswerEvent.content.sdp)) {
+ emit ChatPage::instance()->showNotification("Problem setting up call.");
+ hangUp();
+ }
+ }
+}
+
+void
+CallManager::handleEvent(const RoomEvent<CallHangUp> &callHangUpEvent)
+{
+ nhlog::ui()->debug("WebRTC: call id: {} - incoming CallHangUp ({}) from {}",
+ callHangUpEvent.content.call_id,
+ callHangUpReasonString(callHangUpEvent.content.reason),
+ callHangUpEvent.sender);
+
+ if (callid_ == callHangUpEvent.content.call_id) {
+ MainWindow::instance()->hideOverlay();
+ endCall();
+ }
+}
+
+void
+CallManager::generateCallID()
+{
+ using namespace std::chrono;
+ uint64_t ms = duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
+ callid_ = "c" + std::to_string(ms);
+}
+
+void
+CallManager::clear()
+{
+ roomid_.clear();
+ callid_.clear();
+ remoteICECandidates_.clear();
+}
+
+void
+CallManager::endCall()
+{
+ stopRingtone();
+ clear();
+ session_.end();
+}
+
+void
+CallManager::refreshTurnServer()
+{
+ turnURIs_.clear();
+ turnServerTimer_.start(2000);
+}
+
+void
+CallManager::retrieveTurnServer()
+{
+ http::client()->get_turn_server(
+ [this](const mtx::responses::TurnServer &res, mtx::http::RequestErr err) {
+ if (err) {
+ turnServerTimer_.setInterval(5000);
+ return;
+ }
+ emit turnServerRetrieved(res);
+ });
+}
+
+void
+CallManager::playRingtone(const QString &ringtone, bool repeat)
+{
+ static QMediaPlaylist playlist;
+ playlist.clear();
+ playlist.setPlaybackMode(repeat ? QMediaPlaylist::CurrentItemInLoop
+ : QMediaPlaylist::CurrentItemOnce);
+ playlist.addMedia(QUrl(ringtone));
+ player_.setVolume(100);
+ player_.setPlaylist(&playlist);
+}
+
+void
+CallManager::stopRingtone()
+{
+ player_.setPlaylist(nullptr);
+}
+
+namespace {
+std::vector<std::string>
+getTurnURIs(const mtx::responses::TurnServer &turnServer)
+{
+ // gstreamer expects: turn(s)://username:password@host:port?transport=udp(tcp)
+ // where username and password are percent-encoded
+ std::vector<std::string> ret;
+ for (const auto &uri : turnServer.uris) {
+ if (auto c = uri.find(':'); c == std::string::npos) {
+ nhlog::ui()->error("Invalid TURN server uri: {}", uri);
+ continue;
+ } else {
+ std::string scheme = std::string(uri, 0, c);
+ if (scheme != "turn" && scheme != "turns") {
+ nhlog::ui()->error("Invalid TURN server uri: {}", uri);
+ continue;
+ }
+
+ QString encodedUri =
+ QString::fromStdString(scheme) + "://" +
+ QUrl::toPercentEncoding(QString::fromStdString(turnServer.username)) +
+ ":" +
+ QUrl::toPercentEncoding(QString::fromStdString(turnServer.password)) +
+ "@" + QString::fromStdString(std::string(uri, ++c));
+ ret.push_back(encodedUri.toStdString());
+ }
+ }
+ return ret;
+}
+}
diff --git a/src/CallManager.h b/src/CallManager.h
new file mode 100644
index 00000000..3a406438
--- /dev/null
+++ b/src/CallManager.h
@@ -0,0 +1,75 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <QMediaPlayer>
+#include <QObject>
+#include <QSharedPointer>
+#include <QString>
+#include <QTimer>
+
+#include "mtx/events/collections.hpp"
+#include "mtx/events/voip.hpp"
+
+namespace mtx::responses {
+struct TurnServer;
+}
+
+class UserSettings;
+class WebRTCSession;
+
+class CallManager : public QObject
+{
+ Q_OBJECT
+
+public:
+ CallManager(QSharedPointer<UserSettings>);
+
+ void sendInvite(const QString &roomid);
+ void hangUp(
+ mtx::events::msg::CallHangUp::Reason = mtx::events::msg::CallHangUp::Reason::User);
+ bool onActiveCall();
+ void refreshTurnServer();
+
+public slots:
+ void syncEvent(const mtx::events::collections::TimelineEvents &event);
+
+signals:
+ void newMessage(const QString &roomid, const mtx::events::msg::CallInvite &);
+ void newMessage(const QString &roomid, const mtx::events::msg::CallCandidates &);
+ void newMessage(const QString &roomid, const mtx::events::msg::CallAnswer &);
+ void newMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
+ void turnServerRetrieved(const mtx::responses::TurnServer &);
+ void newCallParty(const QString &userid,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl);
+
+private slots:
+ void retrieveTurnServer();
+
+private:
+ WebRTCSession &session_;
+ QString roomid_;
+ std::string callid_;
+ const uint32_t timeoutms_ = 120000;
+ std::vector<mtx::events::msg::CallCandidates::Candidate> remoteICECandidates_;
+ std::vector<std::string> turnURIs_;
+ QTimer turnServerTimer_;
+ QSharedPointer<UserSettings> settings_;
+ QMediaPlayer player_;
+
+ template<typename T>
+ bool handleEvent_(const mtx::events::collections::TimelineEvents &event);
+ void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &);
+ void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallCandidates> &);
+ void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &);
+ void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &);
+ void answerInvite(const mtx::events::msg::CallInvite &);
+ void generateCallID();
+ void clear();
+ void endCall();
+ void playRingtone(const QString &ringtone, bool repeat);
+ void stopRingtone();
+};
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 909d81eb..31ba38d7 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -22,6 +22,7 @@
#include <QShortcut>
#include <QtConcurrent>
+#include "ActiveCallBar.h"
#include "AvatarProvider.h"
#include "Cache.h"
#include "Cache_p.h"
@@ -41,11 +42,13 @@
#include "UserInfoWidget.h"
#include "UserSettingsPage.h"
#include "Utils.h"
+#include "WebRTCSession.h"
#include "ui/OverlayModal.h"
#include "ui/Theme.h"
#include "notifications/Manager.h"
+#include "dialogs/PlaceCall.h"
#include "dialogs/ReadReceipts.h"
#include "popups/UserMentions.h"
#include "timeline/TimelineViewManager.h"
@@ -69,6 +72,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
, isConnected_(true)
, userSettings_{userSettings}
, notificationsManager(this)
+ , callManager_(userSettings)
{
setObjectName("chatPage");
@@ -124,11 +128,17 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
contentLayout_->setMargin(0);
top_bar_ = new TopRoomBar(this);
- view_manager_ = new TimelineViewManager(userSettings_, this);
+ view_manager_ = new TimelineViewManager(userSettings_, &callManager_, this);
contentLayout_->addWidget(top_bar_);
contentLayout_->addWidget(view_manager_->getWidget());
+ activeCallBar_ = new ActiveCallBar(this);
+ contentLayout_->addWidget(activeCallBar_);
+ activeCallBar_->hide();
+ connect(
+ &callManager_, &CallManager::newCallParty, activeCallBar_, &ActiveCallBar::setCallParty);
+
// Splitter
splitter->addWidget(sideBar_);
splitter->addWidget(content_);
@@ -156,6 +166,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())
@@ -444,6 +459,35 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
roomid, filename, encryptedFile, url, mime, dsize);
});
+ connect(text_input_, &TextInputWidget::callButtonPress, this, [this]() {
+ if (callManager_.onActiveCall()) {
+ callManager_.hangUp();
+ } else {
+ if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
+ roomInfo.member_count != 2) {
+ showNotification("Voice calls are limited to 1:1 rooms.");
+ } else {
+ std::vector<RoomMember> members(
+ cache::getMembers(current_room_.toStdString()));
+ const RoomMember &callee =
+ members.front().user_id == utils::localUser() ? members.back()
+ : members.front();
+ auto dialog = new dialogs::PlaceCall(
+ callee.user_id,
+ callee.display_name,
+ QString::fromStdString(roomInfo.name),
+ QString::fromStdString(roomInfo.avatar_url),
+ userSettings_,
+ MainWindow::instance());
+ connect(dialog, &dialogs::PlaceCall::voice, this, [this]() {
+ callManager_.sendInvite(current_room_);
+ });
+ utils::centerWidget(dialog, MainWindow::instance());
+ dialog->show();
+ }
+ }
+ });
+
connect(room_list_, &RoomList::roomAvatarChanged, this, &ChatPage::updateTopBarAvatar);
connect(
@@ -576,6 +620,11 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
+ connectCallMessage<mtx::events::msg::CallInvite>();
+ connectCallMessage<mtx::events::msg::CallCandidates>();
+ connectCallMessage<mtx::events::msg::CallAnswer>();
+ connectCallMessage<mtx::events::msg::CallHangUp>();
+
instance_ = this;
}
@@ -678,6 +727,8 @@ ChatPage::bootstrap(QString userid, QString homeserver, QString token)
const bool isInitialized = cache::isInitialized();
const auto cacheVersion = cache::formatVersion();
+ callManager_.refreshTurnServer();
+
if (!isInitialized) {
cache::setCurrentFormat();
} else {
@@ -1160,11 +1211,19 @@ ChatPage::leaveRoom(const QString &room_id)
void
ChatPage::inviteUser(QString userid, QString reason)
{
+ auto room = current_room_;
+
+ if (QMessageBox::question(this,
+ tr("Confirm invite"),
+ tr("Do you really want to invite %1 (%2)?")
+ .arg(cache::displayName(current_room_, userid))
+ .arg(userid)) != QMessageBox::Yes)
+ return;
+
http::client()->invite_user(
- current_room_.toStdString(),
+ room.toStdString(),
userid.toStdString(),
- [this, userid, room = current_room_](const mtx::responses::Empty &,
- mtx::http::RequestErr err) {
+ [this, userid, room](const mtx::responses::Empty &, mtx::http::RequestErr err) {
if (err) {
emit showNotification(
tr("Failed to invite %1 to %2: %3")
@@ -1179,11 +1238,19 @@ ChatPage::inviteUser(QString userid, QString reason)
void
ChatPage::kickUser(QString userid, QString reason)
{
+ auto room = current_room_;
+
+ if (QMessageBox::question(this,
+ tr("Confirm kick"),
+ tr("Do you really want to kick %1 (%2)?")
+ .arg(cache::displayName(current_room_, userid))
+ .arg(userid)) != QMessageBox::Yes)
+ return;
+
http::client()->kick_user(
- current_room_.toStdString(),
+ room.toStdString(),
userid.toStdString(),
- [this, userid, room = current_room_](const mtx::responses::Empty &,
- mtx::http::RequestErr err) {
+ [this, userid, room](const mtx::responses::Empty &, mtx::http::RequestErr err) {
if (err) {
emit showNotification(
tr("Failed to kick %1 to %2: %3")
@@ -1198,11 +1265,19 @@ ChatPage::kickUser(QString userid, QString reason)
void
ChatPage::banUser(QString userid, QString reason)
{
+ auto room = current_room_;
+
+ if (QMessageBox::question(this,
+ tr("Confirm ban"),
+ tr("Do you really want to ban %1 (%2)?")
+ .arg(cache::displayName(current_room_, userid))
+ .arg(userid)) != QMessageBox::Yes)
+ return;
+
http::client()->ban_user(
- current_room_.toStdString(),
+ room.toStdString(),
userid.toStdString(),
- [this, userid, room = current_room_](const mtx::responses::Empty &,
- mtx::http::RequestErr err) {
+ [this, userid, room](const mtx::responses::Empty &, mtx::http::RequestErr err) {
if (err) {
emit showNotification(
tr("Failed to ban %1 in %2: %3")
@@ -1217,11 +1292,19 @@ ChatPage::banUser(QString userid, QString reason)
void
ChatPage::unbanUser(QString userid, QString reason)
{
+ auto room = current_room_;
+
+ if (QMessageBox::question(this,
+ tr("Confirm unban"),
+ tr("Do you really want to unban %1 (%2)?")
+ .arg(cache::displayName(current_room_, userid))
+ .arg(userid)) != QMessageBox::Yes)
+ return;
+
http::client()->unban_user(
- current_room_.toStdString(),
+ room.toStdString(),
userid.toStdString(),
- [this, userid, room = current_room_](const mtx::responses::Empty &,
- mtx::http::RequestErr err) {
+ [this, userid, room](const mtx::responses::Empty &, mtx::http::RequestErr err) {
if (err) {
emit showNotification(
tr("Failed to unban %1 in %2: %3")
@@ -1478,3 +1561,13 @@ ChatPage::query_keys(
http::client()->query_keys(req, cb);
}
}
+
+template<typename T>
+void
+ChatPage::connectCallMessage()
+{
+ connect(&callManager_,
+ qOverload<const QString &, const T &>(&CallManager::newMessage),
+ view_manager_,
+ qOverload<const QString &, const T &>(&TimelineViewManager::queueCallMessage));
+}
diff --git a/src/ChatPage.h b/src/ChatPage.h
index 10801342..de4cb4ca 100644
--- a/src/ChatPage.h
+++ b/src/ChatPage.h
@@ -36,11 +36,13 @@
#include <QWidget>
#include "CacheStructs.h"
+#include "CallManager.h"
#include "CommunitiesList.h"
#include "Utils.h"
#include "notifications/Manager.h"
#include "popups/UserMentions.h"
+class ActiveCallBar;
class OverlayModal;
class QuickSwitcher;
class RoomList;
@@ -241,6 +243,9 @@ private:
void showNotificationsDialog(const QPoint &point);
+ template<typename T>
+ void connectCallMessage();
+
QHBoxLayout *topLayout_;
Splitter *splitter;
@@ -260,6 +265,7 @@ private:
TopRoomBar *top_bar_;
TextInputWidget *text_input_;
+ ActiveCallBar *activeCallBar_;
QTimer connectivityTimer_;
std::atomic_bool isConnected_;
@@ -277,6 +283,7 @@ private:
QSharedPointer<UserSettings> userSettings_;
NotificationsManager notificationsManager;
+ CallManager callManager_;
};
template<class Collection>
diff --git a/src/Config.h b/src/Config.h
index f99cf36b..c0624709 100644
--- a/src/Config.h
+++ b/src/Config.h
@@ -53,9 +53,9 @@ namespace strings {
const QString url_html = "<a href=\"\\1\">\\1</a>";
const QRegularExpression url_regex(
// match an URL, that is not quoted, i.e.
- // vvvvvv match quote via negative lookahead/lookbehind vv
- // vvvv atomic match url -> fail if there is a " before or after vvv
- R"((?<!")(?>((www\.(?!\.)|[a-z][a-z0-9+.-]*://)[^\s<>'"]+[^!,\.\s<>'"\]\)\:]))(?!"))");
+ // vvvvvv match quote via negative lookahead/lookbehind vv
+ // vvvv atomic match url -> fail if there is a " before or after vvv
+ R"((?<!["'])(?>((www\.(?!\.)|[a-z][a-z0-9+.-]*://)[^\s<>'"]+[^!,\.\s<>'"\]\)\:]))(?!["']))");
}
// Window geometry.
diff --git a/src/DeviceVerificationFlow.cpp b/src/DeviceVerificationFlow.cpp
index dd828421..00c9602c 100644
--- a/src/DeviceVerificationFlow.cpp
+++ b/src/DeviceVerificationFlow.cpp
@@ -165,6 +165,7 @@ DeviceVerificationFlow::DeviceVerificationFlow(QObject *,
}
if (this->method == DeviceVerificationFlow::Method::Emoji) {
+ std::cout<<info<<std::endl;
this->sasList = this->sas->generate_bytes_emoji(info);
} else if (this->method == DeviceVerificationFlow::Method::Decimal) {
this->sasList = this->sas->generate_bytes_decimal(info);
@@ -235,7 +236,7 @@ DeviceVerificationFlow::DeviceVerificationFlow(QObject *,
&ChatPage::recievedDeviceVerificationReady,
this,
[this](const mtx::events::msg::KeyVerificationReady &msg) {
- if (!sender) {
+ if (!sender && msg.from_device != http::client()->device_id()) {
this->deleteLater();
emit verificationCanceled();
return;
@@ -243,7 +244,7 @@ DeviceVerificationFlow::DeviceVerificationFlow(QObject *,
if (msg.transaction_id.has_value()) {
if (msg.transaction_id.value() != this->transaction_id)
return;
- } else if (msg.relates_to.has_value()) {
+ } else if ((msg.relates_to.has_value() && sender)) {
if (msg.relates_to.value().event_id != this->relation.event_id)
return;
else {
@@ -405,7 +406,7 @@ DeviceVerificationFlow::acceptVerificationRequest()
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationAccept);
}
}
//! responds verification request
@@ -432,7 +433,7 @@ DeviceVerificationFlow::sendVerificationReady()
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationReady);
}
}
//! accepts a verification
@@ -456,7 +457,7 @@ DeviceVerificationFlow::sendVerificationDone()
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationDone);
}
}
//! starts the verification flow
@@ -489,7 +490,7 @@ DeviceVerificationFlow::startVerificationRequest()
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
this->canonical_json = nlohmann::json(req);
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationStart);
}
}
//! sends a verification request
@@ -525,7 +526,7 @@ DeviceVerificationFlow::sendVerificationRequest()
req.body = "User is requesting to verify keys with you. However, your client does "
"not support this method, so you will need to use the legacy method of "
"key verification.";
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationRequest);
}
}
//! cancels a verification flow
@@ -573,7 +574,7 @@ DeviceVerificationFlow::cancelVerification(DeviceVerificationFlow::Error error_c
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationCancel);
this->deleteLater();
}
@@ -612,7 +613,7 @@ DeviceVerificationFlow::sendVerificationKey()
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationKey);
}
}
//! sends the mac of the keys
@@ -659,7 +660,7 @@ DeviceVerificationFlow::sendVerificationMac()
});
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) {
req.relates_to = this->relation;
- (model_)->sendMessage(req);
+ (model_)->sendMessageEvent(req, mtx::events::EventType::KeyVerificationMac);
}
}
//! Completes the verification flow
diff --git a/src/DeviceVerificationFlow.h b/src/DeviceVerificationFlow.h
index 6b2ab81f..d2df0bbc 100644
--- a/src/DeviceVerificationFlow.h
+++ b/src/DeviceVerificationFlow.h
@@ -1,4 +1,4 @@
-#pragma once
+ith#pragma once
#include "Olm.h"
diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp
index 24e2f35b..b62be9a5 100644
--- a/src/EventAccessors.cpp
+++ b/src/EventAccessors.cpp
@@ -1,5 +1,7 @@
#include "EventAccessors.h"
+#include <algorithm>
+#include <cctype>
#include <type_traits>
namespace {
@@ -72,6 +74,29 @@ struct EventRoomTopic
}
};
+struct CallType
+{
+ template<class T>
+ std::string operator()(const T &e)
+ {
+ if constexpr (std::is_same_v<mtx::events::RoomEvent<mtx::events::msg::CallInvite>,
+ T>) {
+ const char video[] = "m=video";
+ const std::string &sdp = e.content.sdp;
+ return std::search(sdp.cbegin(),
+ sdp.cend(),
+ std::cbegin(video),
+ std::cend(video) - 1,
+ [](unsigned char c1, unsigned char c2) {
+ return std::tolower(c1) == std::tolower(c2);
+ }) != sdp.cend()
+ ? "video"
+ : "voice";
+ }
+ return std::string();
+ }
+};
+
struct EventBody
{
template<class C>
@@ -354,6 +379,12 @@ mtx::accessors::room_topic(const mtx::events::collections::TimelineEvents &event
}
std::string
+mtx::accessors::call_type(const mtx::events::collections::TimelineEvents &event)
+{
+ return std::visit(CallType{}, event);
+}
+
+std::string
mtx::accessors::body(const mtx::events::collections::TimelineEvents &event)
{
return std::visit(EventBody{}, event);
diff --git a/src/EventAccessors.h b/src/EventAccessors.h
index 8f08ef1c..0cdc5f89 100644
--- a/src/EventAccessors.h
+++ b/src/EventAccessors.h
@@ -31,6 +31,9 @@ std::string
room_topic(const mtx::events::collections::TimelineEvents &event);
std::string
+call_type(const mtx::events::collections::TimelineEvents &event);
+
+std::string
body(const mtx::events::collections::TimelineEvents &event);
std::string
diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp
index 63b524c8..59557bff 100644
--- a/src/MainWindow.cpp
+++ b/src/MainWindow.cpp
@@ -17,6 +17,7 @@
#include <QApplication>
#include <QLayout>
+#include <QMessageBox>
#include <QPluginLoader>
#include <QSettings>
#include <QShortcut>
@@ -35,6 +36,7 @@
#include "TrayIcon.h"
#include "UserSettingsPage.h"
#include "Utils.h"
+#include "WebRTCSession.h"
#include "WelcomePage.h"
#include "ui/LoadingIndicator.h"
#include "ui/OverlayModal.h"
@@ -285,6 +287,14 @@ MainWindow::showChatPage()
void
MainWindow::closeEvent(QCloseEvent *event)
{
+ if (WebRTCSession::instance().state() != WebRTCSession::State::DISCONNECTED) {
+ if (QMessageBox::question(this, "nheko", "A call is in progress. Quit?") !=
+ QMessageBox::Yes) {
+ event->ignore();
+ return;
+ }
+ }
+
if (!qApp->isSavingSession() && isVisible() && pageSupportsTray() &&
userSettings_->tray()) {
event->ignore();
@@ -424,8 +434,17 @@ void
MainWindow::openLogoutDialog()
{
auto dialog = new dialogs::Logout(this);
- connect(
- dialog, &dialogs::Logout::loggingOut, this, [this]() { chat_page_->initiateLogout(); });
+ connect(dialog, &dialogs::Logout::loggingOut, this, [this]() {
+ if (WebRTCSession::instance().state() != WebRTCSession::State::DISCONNECTED) {
+ if (QMessageBox::question(
+ this, "nheko", "A call is in progress. Log out?") !=
+ QMessageBox::Yes) {
+ return;
+ }
+ WebRTCSession::instance().end();
+ }
+ chat_page_->initiateLogout();
+ });
showDialog(dialog);
}
diff --git a/src/Olm.cpp b/src/Olm.cpp
index 9e1a4ed9..74af61dd 100644
--- a/src/Olm.cpp
+++ b/src/Olm.cpp
@@ -4,6 +4,7 @@
#include "Olm.h"
#include "Cache.h"
+#include "Cache_p.h"
#include "ChatPage.h"
#include "DeviceVerificationFlow.h"
#include "Logging.h"
@@ -365,32 +366,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;
+ 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: {}", body.dump(2));
+ nhlog::crypto()->debug("m.room_key_request: {}", json(request).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);
- }
+ 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 {}:{}", e.sender, e.content.device_id);
- });
+ nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices",
+ e.sender,
+ e.content.device_id);
+ });
}
void
@@ -610,4 +615,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 3e3915bb..4edd8376 100644
--- a/src/TextInputWidget.cpp
+++ b/src/TextInputWidget.cpp
@@ -453,6 +453,15 @@ TextInputWidget::TextInputWidget(QWidget *parent)
topLayout_->setSpacing(0);
topLayout_->setContentsMargins(13, 1, 13, 0);
+#ifdef GSTREAMER_AVAILABLE
+ callBtn_ = new FlatButton(this);
+ changeCallButtonState(WebRTCSession::State::DISCONNECTED);
+ connect(&WebRTCSession::instance(),
+ &WebRTCSession::stateChanged,
+ this,
+ &TextInputWidget::changeCallButtonState);
+#endif
+
QIcon send_file_icon;
send_file_icon.addFile(":/icons/icons/ui/paper-clip-outline.png");
@@ -521,6 +530,9 @@ TextInputWidget::TextInputWidget(QWidget *parent)
emojiBtn_->setIcon(emoji_icon);
emojiBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
+#ifdef GSTREAMER_AVAILABLE
+ topLayout_->addWidget(callBtn_);
+#endif
topLayout_->addWidget(sendFileBtn_);
topLayout_->addWidget(input_);
topLayout_->addWidget(emojiBtn_);
@@ -528,6 +540,9 @@ TextInputWidget::TextInputWidget(QWidget *parent)
setLayout(topLayout_);
+#ifdef GSTREAMER_AVAILABLE
+ connect(callBtn_, &FlatButton::clicked, this, &TextInputWidget::callButtonPress);
+#endif
connect(sendMessageBtn_, &FlatButton::clicked, input_, &FilteredTextEdit::submit);
connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection()));
connect(input_, &FilteredTextEdit::message, this, &TextInputWidget::sendTextMessage);
@@ -566,27 +581,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();
}
}
@@ -618,7 +635,7 @@ TextInputWidget::showUploadSpinner()
topLayout_->removeWidget(sendFileBtn_);
sendFileBtn_->hide();
- topLayout_->insertWidget(0, spinner_);
+ topLayout_->insertWidget(1, spinner_);
spinner_->start();
}
@@ -626,7 +643,7 @@ void
TextInputWidget::hideUploadSpinner()
{
topLayout_->removeWidget(spinner_);
- topLayout_->insertWidget(0, sendFileBtn_);
+ topLayout_->insertWidget(1, sendFileBtn_);
sendFileBtn_->show();
spinner_->stop();
}
@@ -652,3 +669,19 @@ TextInputWidget::paintEvent(QPaintEvent *)
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}
+
+void
+TextInputWidget::changeCallButtonState(WebRTCSession::State state)
+{
+ QIcon icon;
+ if (state == WebRTCSession::State::ICEFAILED ||
+ state == WebRTCSession::State::DISCONNECTED) {
+ callBtn_->setToolTip(tr("Place a call"));
+ icon.addFile(":/icons/icons/ui/place-call.png");
+ } else {
+ callBtn_->setToolTip(tr("Hang up"));
+ icon.addFile(":/icons/icons/ui/end-call.png");
+ }
+ callBtn_->setIcon(icon);
+ callBtn_->setIconSize(QSize(ButtonHeight * 1.1, ButtonHeight * 1.1));
+}
diff --git a/src/TextInputWidget.h b/src/TextInputWidget.h
index a0105eb0..2473c13a 100644
--- a/src/TextInputWidget.h
+++ b/src/TextInputWidget.h
@@ -26,6 +26,7 @@
#include <QTextEdit>
#include <QWidget>
+#include "WebRTCSession.h"
#include "dialogs/PreviewUploadOverlay.h"
#include "emoji/PickButton.h"
#include "popups/SuggestionsPopup.h"
@@ -149,6 +150,7 @@ public slots:
void openFileSelection();
void hideUploadSpinner();
void focusLineEdit() { input_->setFocus(); }
+ void changeCallButtonState(WebRTCSession::State);
private slots:
void addSelectedEmoji(const QString &emoji);
@@ -156,11 +158,13 @@ private slots:
signals:
void sendTextMessage(const QString &msg);
void sendEmoteMessage(QString msg);
+ void clearRoomTimeline();
void heightChanged(int height);
void uploadMedia(const QSharedPointer<QIODevice> data,
QString mimeClass,
const QString &filename);
+ void callButtonPress();
void sendJoinRoomRequest(const QString &room);
void sendInviteRoomRequest(const QString &userid, const QString &reason);
@@ -185,6 +189,7 @@ private:
LoadingIndicator *spinner_;
+ FlatButton *callBtn_;
FlatButton *sendFileBtn_;
FlatButton *sendMessageBtn_;
emoji::PickButton *emojiBtn_;
diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 05ff6d38..ab5658a4 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -77,6 +77,8 @@ UserSettings::load()
presence_ =
settings.value("user/presence", QVariant::fromValue(Presence::AutomaticPresence))
.value<Presence>();
+ useStunServer_ = settings.value("user/use_stun_server", false).toBool();
+ defaultAudioSource_ = settings.value("user/default_audio_source", QString()).toString();
applyTheme();
}
@@ -280,6 +282,26 @@ UserSettings::setTheme(QString theme)
}
void
+UserSettings::setUseStunServer(bool useStunServer)
+{
+ if (useStunServer == useStunServer_)
+ return;
+ useStunServer_ = useStunServer;
+ emit useStunServerChanged(useStunServer);
+ save();
+}
+
+void
+UserSettings::setDefaultAudioSource(const QString &defaultAudioSource)
+{
+ if (defaultAudioSource == defaultAudioSource_)
+ return;
+ defaultAudioSource_ = defaultAudioSource;
+ emit defaultAudioSourceChanged(defaultAudioSource);
+ save();
+}
+
+void
UserSettings::applyTheme()
{
QFile stylefile;
@@ -364,6 +386,8 @@ UserSettings::save()
settings.setValue("font_family", font_);
settings.setValue("emoji_font_family", emojiFont_);
settings.setValue("presence", QVariant::fromValue(presence_));
+ settings.setValue("use_stun_server", useStunServer_);
+ settings.setValue("default_audio_source", defaultAudioSource_);
settings.endGroup();
@@ -429,6 +453,7 @@ UserSettingsPage::UserSettingsPage(QSharedPointer<UserSettings> settings, QWidge
markdown_ = new Toggle{this};
desktopNotifications_ = new Toggle{this};
alertOnNotification_ = new Toggle{this};
+ useStunServer_ = new Toggle{this};
scaleFactorCombo_ = new QComboBox{this};
fontSizeCombo_ = new QComboBox{this};
fontSelectionCombo_ = new QComboBox{this};
@@ -482,6 +507,15 @@ UserSettingsPage::UserSettingsPage(QSharedPointer<UserSettings> settings, QWidge
timelineMaxWidthSpin_->setMaximum(100'000'000);
timelineMaxWidthSpin_->setSingleStep(10);
+ auto callsLabel = new QLabel{tr("CALLS"), this};
+ callsLabel->setFixedHeight(callsLabel->minimumHeight() + LayoutTopMargin);
+ callsLabel->setAlignment(Qt::AlignBottom);
+ callsLabel->setFont(font);
+ useStunServer_ = new Toggle{this};
+
+ defaultAudioSourceValue_ = new QLabel(this);
+ defaultAudioSourceValue_->setFont(font);
+
auto encryptionLabel_ = new QLabel{tr("ENCRYPTION"), this};
encryptionLabel_->setFixedHeight(encryptionLabel_->minimumHeight() + LayoutTopMargin);
encryptionLabel_->setAlignment(Qt::AlignBottom);
@@ -612,6 +646,14 @@ UserSettingsPage::UserSettingsPage(QSharedPointer<UserSettings> settings, QWidge
#endif
boxWrap(tr("Theme"), themeCombo_);
+
+ formLayout_->addRow(callsLabel);
+ formLayout_->addRow(new HorizontalLine{this});
+ boxWrap(tr("Allow fallback call assist server"),
+ useStunServer_,
+ tr("Will use turn.matrix.org as assist when your home server does not offer one."));
+ boxWrap(tr("Default audio source device"), defaultAudioSourceValue_);
+
formLayout_->addRow(encryptionLabel_);
formLayout_->addRow(new HorizontalLine{this});
boxWrap(tr("Device ID"), deviceIdValue_);
@@ -724,6 +766,10 @@ UserSettingsPage::UserSettingsPage(QSharedPointer<UserSettings> settings, QWidge
settings_->setEnlargeEmojiOnlyMessages(!disabled);
});
+ connect(useStunServer_, &Toggle::toggled, this, [this](bool disabled) {
+ settings_->setUseStunServer(!disabled);
+ });
+
connect(timelineMaxWidthSpin_,
qOverload<int>(&QSpinBox::valueChanged),
this,
@@ -766,6 +812,8 @@ UserSettingsPage::showEvent(QShowEvent *)
enlargeEmojiOnlyMessages_->setState(!settings_->enlargeEmojiOnlyMessages());
deviceIdValue_->setText(QString::fromStdString(http::client()->device_id()));
timelineMaxWidthSpin_->setValue(settings_->timelineMaxWidth());
+ useStunServer_->setState(!settings_->useStunServer());
+ defaultAudioSourceValue_->setText(settings_->defaultAudioSource());
deviceFingerprintValue_->setText(
utils::humanReadableFingerprint(olm::client()->identity_keys().ed25519));
diff --git a/src/UserSettingsPage.h b/src/UserSettingsPage.h
index d2a1c641..52ff9466 100644
--- a/src/UserSettingsPage.h
+++ b/src/UserSettingsPage.h
@@ -71,6 +71,10 @@ class UserSettings : public QObject
Q_PROPERTY(
QString emojiFont READ emojiFont WRITE setEmojiFontFamily NOTIFY emojiFontChanged)
Q_PROPERTY(Presence presence READ presence WRITE setPresence NOTIFY presenceChanged)
+ Q_PROPERTY(
+ bool useStunServer READ useStunServer WRITE setUseStunServer NOTIFY useStunServerChanged)
+ Q_PROPERTY(QString defaultAudioSource READ defaultAudioSource WRITE setDefaultAudioSource
+ NOTIFY defaultAudioSourceChanged)
public:
UserSettings();
@@ -107,6 +111,8 @@ public:
void setAvatarCircles(bool state);
void setDecryptSidebar(bool state);
void setPresence(Presence state);
+ void setUseStunServer(bool state);
+ void setDefaultAudioSource(const QString &deviceName);
QString theme() const { return !theme_.isEmpty() ? theme_ : defaultTheme_; }
bool messageHoverHighlight() const { return messageHoverHighlight_; }
@@ -132,6 +138,8 @@ public:
QString font() const { return font_; }
QString emojiFont() const { return emojiFont_; }
Presence presence() const { return presence_; }
+ bool useStunServer() const { return useStunServer_; }
+ QString defaultAudioSource() const { return defaultAudioSource_; }
signals:
void groupViewStateChanged(bool state);
@@ -154,6 +162,8 @@ signals:
void fontChanged(QString state);
void emojiFontChanged(QString state);
void presenceChanged(Presence state);
+ void useStunServerChanged(bool state);
+ void defaultAudioSourceChanged(const QString &deviceName);
private:
// Default to system theme if QT_QPA_PLATFORMTHEME var is set.
@@ -181,6 +191,8 @@ private:
QString font_;
QString emojiFont_;
Presence presence_;
+ bool useStunServer_;
+ QString defaultAudioSource_;
};
class HorizontalLine : public QFrame
@@ -234,9 +246,11 @@ private:
Toggle *desktopNotifications_;
Toggle *alertOnNotification_;
Toggle *avatarCircles_;
+ Toggle *useStunServer_;
Toggle *decryptSidebar_;
QLabel *deviceFingerprintValue_;
QLabel *deviceIdValue_;
+ QLabel *defaultAudioSourceValue_;
QComboBox *themeCombo_;
QComboBox *scaleFactorCombo_;
diff --git a/src/Utils.cpp b/src/Utils.cpp
index 26ea124c..0bfc82c3 100644
--- a/src/Utils.cpp
+++ b/src/Utils.cpp
@@ -35,14 +35,13 @@ createDescriptionInfo(const Event &event, const QString &localUser, const QStrin
const auto username = cache::displayName(room_id, sender);
const auto ts = QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts);
- return DescInfo{
- QString::fromStdString(msg.event_id),
- sender,
- utils::messageDescription<T>(
- username, QString::fromStdString(msg.content.body).trimmed(), sender == localUser),
- utils::descriptiveTime(ts),
- msg.origin_server_ts,
- ts};
+ return DescInfo{QString::fromStdString(msg.event_id),
+ sender,
+ utils::messageDescription<T>(
+ username, utils::event_body(event).trimmed(), sender == localUser),
+ utils::descriptiveTime(ts),
+ msg.origin_server_ts,
+ ts};
}
QString
@@ -156,14 +155,17 @@ utils::getMessageDescription(const TimelineEvent &event,
const QString &localUser,
const QString &room_id)
{
- using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
- using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
- using File = mtx::events::RoomEvent<mtx::events::msg::File>;
- using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
- using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
- using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
- using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
- using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
+ using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
+ using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
+ using File = mtx::events::RoomEvent<mtx::events::msg::File>;
+ using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
+ using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
+ using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
+ using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
+ using CallInvite = mtx::events::RoomEvent<mtx::events::msg::CallInvite>;
+ using CallAnswer = mtx::events::RoomEvent<mtx::events::msg::CallAnswer>;
+ using CallHangUp = mtx::events::RoomEvent<mtx::events::msg::CallHangUp>;
+ using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
if (std::holds_alternative<Audio>(event)) {
return createDescriptionInfo<Audio>(event, localUser, room_id);
@@ -179,6 +181,12 @@ utils::getMessageDescription(const TimelineEvent &event,
return createDescriptionInfo<Text>(event, localUser, room_id);
} else if (std::holds_alternative<Video>(event)) {
return createDescriptionInfo<Video>(event, localUser, room_id);
+ } else if (std::holds_alternative<CallInvite>(event)) {
+ return createDescriptionInfo<CallInvite>(event, localUser, room_id);
+ } else if (std::holds_alternative<CallAnswer>(event)) {
+ return createDescriptionInfo<CallAnswer>(event, localUser, room_id);
+ } else if (std::holds_alternative<CallHangUp>(event)) {
+ return createDescriptionInfo<CallHangUp>(event, localUser, room_id);
} else if (std::holds_alternative<mtx::events::Sticker>(event)) {
return createDescriptionInfo<mtx::events::Sticker>(event, localUser, room_id);
} else if (auto msg = std::get_if<Encrypted>(&event); msg != nullptr) {
diff --git a/src/Utils.h b/src/Utils.h
index 07a4a648..5e7fb601 100644
--- a/src/Utils.h
+++ b/src/Utils.h
@@ -88,15 +88,18 @@ messageDescription(const QString &username = "",
const QString &body = "",
const bool isLocal = false)
{
- using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
- using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
- using File = mtx::events::RoomEvent<mtx::events::msg::File>;
- using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
- using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
- using Sticker = mtx::events::Sticker;
- using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
- using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
- using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
+ using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
+ using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
+ using File = mtx::events::RoomEvent<mtx::events::msg::File>;
+ using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
+ using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
+ using Sticker = mtx::events::Sticker;
+ using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
+ using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
+ using CallInvite = mtx::events::RoomEvent<mtx::events::msg::CallInvite>;
+ using CallAnswer = mtx::events::RoomEvent<mtx::events::msg::CallAnswer>;
+ using CallHangUp = mtx::events::RoomEvent<mtx::events::msg::CallHangUp>;
+ using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
if (std::is_same<T, Audio>::value) {
if (isLocal)
@@ -164,6 +167,30 @@ messageDescription(const QString &username = "",
return QCoreApplication::translate("message-description sent:",
"%1 sent an encrypted message")
.arg(username);
+ } else if (std::is_same<T, CallInvite>::value) {
+ if (isLocal)
+ return QCoreApplication::translate("message-description sent:",
+ "You placed a call");
+ else
+ return QCoreApplication::translate("message-description sent:",
+ "%1 placed a call")
+ .arg(username);
+ } else if (std::is_same<T, CallAnswer>::value) {
+ if (isLocal)
+ return QCoreApplication::translate("message-description sent:",
+ "You answered a call");
+ else
+ return QCoreApplication::translate("message-description sent:",
+ "%1 answered a call")
+ .arg(username);
+ } else if (std::is_same<T, CallHangUp>::value) {
+ if (isLocal)
+ return QCoreApplication::translate("message-description sent:",
+ "You ended a call");
+ else
+ return QCoreApplication::translate("message-description sent:",
+ "%1 ended a call")
+ .arg(username);
} else {
return QCoreApplication::translate("utils", "Unknown Message Type");
}
diff --git a/src/WebRTCSession.cpp b/src/WebRTCSession.cpp
new file mode 100644
index 00000000..64172e61
--- /dev/null
+++ b/src/WebRTCSession.cpp
@@ -0,0 +1,697 @@
+#include <cctype>
+
+#include "Logging.h"
+#include "WebRTCSession.h"
+
+#ifdef GSTREAMER_AVAILABLE
+extern "C"
+{
+#include "gst/gst.h"
+#include "gst/sdp/sdp.h"
+
+#define GST_USE_UNSTABLE_API
+#include "gst/webrtc/webrtc.h"
+}
+#endif
+
+Q_DECLARE_METATYPE(WebRTCSession::State)
+
+WebRTCSession::WebRTCSession()
+ : QObject()
+{
+ qRegisterMetaType<WebRTCSession::State>();
+ connect(this, &WebRTCSession::stateChanged, this, &WebRTCSession::setState);
+}
+
+bool
+WebRTCSession::init(std::string *errorMessage)
+{
+#ifdef GSTREAMER_AVAILABLE
+ if (initialised_)
+ return true;
+
+ GError *error = nullptr;
+ if (!gst_init_check(nullptr, nullptr, &error)) {
+ std::string strError = std::string("WebRTC: failed to initialise GStreamer: ");
+ if (error) {
+ strError += error->message;
+ g_error_free(error);
+ }
+ nhlog::ui()->error(strError);
+ if (errorMessage)
+ *errorMessage = strError;
+ return false;
+ }
+
+ gchar *version = gst_version_string();
+ std::string gstVersion(version);
+ g_free(version);
+ nhlog::ui()->info("WebRTC: initialised " + gstVersion);
+
+ // GStreamer Plugins:
+ // Base: audioconvert, audioresample, opus, playback, volume
+ // Good: autodetect, rtpmanager
+ // Bad: dtls, srtp, webrtc
+ // libnice [GLib]: nice
+ initialised_ = true;
+ std::string strError = gstVersion + ": Missing plugins: ";
+ const gchar *needed[] = {"audioconvert",
+ "audioresample",
+ "autodetect",
+ "dtls",
+ "nice",
+ "opus",
+ "playback",
+ "rtpmanager",
+ "srtp",
+ "volume",
+ "webrtc",
+ nullptr};
+ GstRegistry *registry = gst_registry_get();
+ for (guint i = 0; i < g_strv_length((gchar **)needed); i++) {
+ GstPlugin *plugin = gst_registry_find_plugin(registry, needed[i]);
+ if (!plugin) {
+ strError += std::string(needed[i]) + " ";
+ initialised_ = false;
+ continue;
+ }
+ gst_object_unref(plugin);
+ }
+
+ if (!initialised_) {
+ nhlog::ui()->error(strError);
+ if (errorMessage)
+ *errorMessage = strError;
+ }
+ return initialised_;
+#else
+ (void)errorMessage;
+ return false;
+#endif
+}
+
+#ifdef GSTREAMER_AVAILABLE
+namespace {
+bool isoffering_;
+std::string localsdp_;
+std::vector<mtx::events::msg::CallCandidates::Candidate> localcandidates_;
+
+gboolean
+newBusMessage(GstBus *bus G_GNUC_UNUSED, GstMessage *msg, gpointer user_data)
+{
+ WebRTCSession *session = static_cast<WebRTCSession *>(user_data);
+ switch (GST_MESSAGE_TYPE(msg)) {
+ case GST_MESSAGE_EOS:
+ nhlog::ui()->error("WebRTC: end of stream");
+ session->end();
+ break;
+ case GST_MESSAGE_ERROR:
+ GError *error;
+ gchar *debug;
+ gst_message_parse_error(msg, &error, &debug);
+ nhlog::ui()->error(
+ "WebRTC: error from element {}: {}", GST_OBJECT_NAME(msg->src), error->message);
+ g_clear_error(&error);
+ g_free(debug);
+ session->end();
+ break;
+ default:
+ break;
+ }
+ return TRUE;
+}
+
+GstWebRTCSessionDescription *
+parseSDP(const std::string &sdp, GstWebRTCSDPType type)
+{
+ GstSDPMessage *msg;
+ gst_sdp_message_new(&msg);
+ if (gst_sdp_message_parse_buffer((guint8 *)sdp.c_str(), sdp.size(), msg) == GST_SDP_OK) {
+ return gst_webrtc_session_description_new(type, msg);
+ } else {
+ nhlog::ui()->error("WebRTC: failed to parse remote session description");
+ gst_object_unref(msg);
+ return nullptr;
+ }
+}
+
+void
+setLocalDescription(GstPromise *promise, gpointer webrtc)
+{
+ const GstStructure *reply = gst_promise_get_reply(promise);
+ gboolean isAnswer = gst_structure_id_has_field(reply, g_quark_from_string("answer"));
+ GstWebRTCSessionDescription *gstsdp = nullptr;
+ gst_structure_get(reply,
+ isAnswer ? "answer" : "offer",
+ GST_TYPE_WEBRTC_SESSION_DESCRIPTION,
+ &gstsdp,
+ nullptr);
+ gst_promise_unref(promise);
+ g_signal_emit_by_name(webrtc, "set-local-description", gstsdp, nullptr);
+
+ gchar *sdp = gst_sdp_message_as_text(gstsdp->sdp);
+ localsdp_ = std::string(sdp);
+ g_free(sdp);
+ gst_webrtc_session_description_free(gstsdp);
+
+ nhlog::ui()->debug(
+ "WebRTC: local description set ({}):\n{}", isAnswer ? "answer" : "offer", localsdp_);
+}
+
+void
+createOffer(GstElement *webrtc)
+{
+ // create-offer first, then set-local-description
+ GstPromise *promise =
+ gst_promise_new_with_change_func(setLocalDescription, webrtc, nullptr);
+ g_signal_emit_by_name(webrtc, "create-offer", nullptr, promise);
+}
+
+void
+createAnswer(GstPromise *promise, gpointer webrtc)
+{
+ // create-answer first, then set-local-description
+ gst_promise_unref(promise);
+ promise = gst_promise_new_with_change_func(setLocalDescription, webrtc, nullptr);
+ g_signal_emit_by_name(webrtc, "create-answer", nullptr, promise);
+}
+
+#if GST_CHECK_VERSION(1, 17, 0)
+void
+iceGatheringStateChanged(GstElement *webrtc,
+ GParamSpec *pspec G_GNUC_UNUSED,
+ gpointer user_data G_GNUC_UNUSED)
+{
+ GstWebRTCICEGatheringState newState;
+ g_object_get(webrtc, "ice-gathering-state", &newState, nullptr);
+ if (newState == GST_WEBRTC_ICE_GATHERING_STATE_COMPLETE) {
+ nhlog::ui()->debug("WebRTC: GstWebRTCICEGatheringState -> Complete");
+ if (isoffering_) {
+ emit WebRTCSession::instance().offerCreated(localsdp_, localcandidates_);
+ emit WebRTCSession::instance().stateChanged(
+ WebRTCSession::State::OFFERSENT);
+ } else {
+ emit WebRTCSession::instance().answerCreated(localsdp_, localcandidates_);
+ emit WebRTCSession::instance().stateChanged(
+ WebRTCSession::State::ANSWERSENT);
+ }
+ }
+}
+
+#else
+
+gboolean
+onICEGatheringCompletion(gpointer timerid)
+{
+ *(guint *)(timerid) = 0;
+ if (isoffering_) {
+ emit WebRTCSession::instance().offerCreated(localsdp_, localcandidates_);
+ emit WebRTCSession::instance().stateChanged(WebRTCSession::State::OFFERSENT);
+ } else {
+ emit WebRTCSession::instance().answerCreated(localsdp_, localcandidates_);
+ emit WebRTCSession::instance().stateChanged(WebRTCSession::State::ANSWERSENT);
+ }
+ return FALSE;
+}
+#endif
+
+void
+addLocalICECandidate(GstElement *webrtc G_GNUC_UNUSED,
+ guint mlineIndex,
+ gchar *candidate,
+ gpointer 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;
+ }
+
+ // 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
+ static guint timerid = 0;
+ if (timerid)
+ g_source_remove(timerid);
+
+ timerid = g_timeout_add(100, onICEGatheringCompletion, &timerid);
+#endif
+}
+
+void
+iceConnectionStateChanged(GstElement *webrtc,
+ GParamSpec *pspec G_GNUC_UNUSED,
+ gpointer user_data G_GNUC_UNUSED)
+{
+ GstWebRTCICEConnectionState newState;
+ g_object_get(webrtc, "ice-connection-state", &newState, nullptr);
+ switch (newState) {
+ case GST_WEBRTC_ICE_CONNECTION_STATE_CHECKING:
+ nhlog::ui()->debug("WebRTC: GstWebRTCICEConnectionState -> Checking");
+ emit WebRTCSession::instance().stateChanged(WebRTCSession::State::CONNECTING);
+ break;
+ case GST_WEBRTC_ICE_CONNECTION_STATE_FAILED:
+ nhlog::ui()->error("WebRTC: GstWebRTCICEConnectionState -> Failed");
+ emit WebRTCSession::instance().stateChanged(WebRTCSession::State::ICEFAILED);
+ break;
+ default:
+ break;
+ }
+}
+
+void
+linkNewPad(GstElement *decodebin G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe)
+{
+ GstCaps *caps = gst_pad_get_current_caps(newpad);
+ if (!caps)
+ return;
+
+ const gchar *name = gst_structure_get_name(gst_caps_get_structure(caps, 0));
+ gst_caps_unref(caps);
+
+ GstPad *queuepad = nullptr;
+ if (g_str_has_prefix(name, "audio")) {
+ nhlog::ui()->debug("WebRTC: received incoming audio stream");
+ GstElement *queue = gst_element_factory_make("queue", nullptr);
+ GstElement *convert = gst_element_factory_make("audioconvert", nullptr);
+ 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);
+ queuepad = gst_element_get_static_pad(queue, "sink");
+ }
+
+ if (queuepad) {
+ if (GST_PAD_LINK_FAILED(gst_pad_link(newpad, queuepad)))
+ nhlog::ui()->error("WebRTC: unable to link new pad");
+ else {
+ emit WebRTCSession::instance().stateChanged(
+ WebRTCSession::State::CONNECTED);
+ }
+ gst_object_unref(queuepad);
+ }
+}
+
+void
+addDecodeBin(GstElement *webrtc G_GNUC_UNUSED, GstPad *newpad, GstElement *pipe)
+{
+ if (GST_PAD_DIRECTION(newpad) != GST_PAD_SRC)
+ return;
+
+ nhlog::ui()->debug("WebRTC: received incoming stream");
+ GstElement *decodebin = gst_element_factory_make("decodebin", nullptr);
+ g_signal_connect(decodebin, "pad-added", G_CALLBACK(linkNewPad), pipe);
+ gst_bin_add(GST_BIN(pipe), decodebin);
+ gst_element_sync_state_with_parent(decodebin);
+ GstPad *sinkpad = gst_element_get_static_pad(decodebin, "sink");
+ if (GST_PAD_LINK_FAILED(gst_pad_link(newpad, sinkpad)))
+ nhlog::ui()->error("WebRTC: unable to link new pad");
+ gst_object_unref(sinkpad);
+}
+
+std::string::const_iterator
+findName(const std::string &sdp, const std::string &name)
+{
+ return std::search(
+ sdp.cbegin(),
+ sdp.cend(),
+ name.cbegin(),
+ name.cend(),
+ [](unsigned char c1, unsigned char c2) { return std::tolower(c1) == std::tolower(c2); });
+}
+
+int
+getPayloadType(const std::string &sdp, const std::string &name)
+{
+ // eg a=rtpmap:111 opus/48000/2
+ auto e = findName(sdp, name);
+ if (e == sdp.cend()) {
+ nhlog::ui()->error("WebRTC: remote offer - " + name + " attribute missing");
+ return -1;
+ }
+
+ if (auto s = sdp.rfind(':', e - sdp.cbegin()); s == std::string::npos) {
+ nhlog::ui()->error("WebRTC: remote offer - unable to determine " + name +
+ " payload type");
+ return -1;
+ } else {
+ ++s;
+ try {
+ return std::stoi(std::string(sdp, s, e - sdp.cbegin() - s));
+ } catch (...) {
+ nhlog::ui()->error("WebRTC: remote offer - unable to determine " + name +
+ " payload type");
+ }
+ }
+ return -1;
+}
+}
+
+bool
+WebRTCSession::createOffer()
+{
+ isoffering_ = true;
+ localsdp_.clear();
+ localcandidates_.clear();
+ return startPipeline(111); // a dynamic opus payload type
+}
+
+bool
+WebRTCSession::acceptOffer(const std::string &sdp)
+{
+ nhlog::ui()->debug("WebRTC: received offer:\n{}", sdp);
+ if (state_ != State::DISCONNECTED)
+ return false;
+
+ isoffering_ = false;
+ localsdp_.clear();
+ localcandidates_.clear();
+
+ int opusPayloadType = getPayloadType(sdp, "opus");
+ if (opusPayloadType == -1)
+ return false;
+
+ GstWebRTCSessionDescription *offer = parseSDP(sdp, GST_WEBRTC_SDP_TYPE_OFFER);
+ if (!offer)
+ return false;
+
+ if (!startPipeline(opusPayloadType)) {
+ gst_webrtc_session_description_free(offer);
+ return false;
+ }
+
+ // set-remote-description first, then create-answer
+ GstPromise *promise = gst_promise_new_with_change_func(createAnswer, webrtc_, nullptr);
+ g_signal_emit_by_name(webrtc_, "set-remote-description", offer, promise);
+ gst_webrtc_session_description_free(offer);
+ return true;
+}
+
+bool
+WebRTCSession::acceptAnswer(const std::string &sdp)
+{
+ nhlog::ui()->debug("WebRTC: received answer:\n{}", sdp);
+ if (state_ != State::OFFERSENT)
+ return false;
+
+ GstWebRTCSessionDescription *answer = parseSDP(sdp, GST_WEBRTC_SDP_TYPE_ANSWER);
+ if (!answer) {
+ end();
+ return false;
+ }
+
+ g_signal_emit_by_name(webrtc_, "set-remote-description", answer, nullptr);
+ gst_webrtc_session_description_free(answer);
+ return true;
+}
+
+void
+WebRTCSession::acceptICECandidates(
+ const std::vector<mtx::events::msg::CallCandidates::Candidate> &candidates)
+{
+ if (state_ >= State::INITIATED) {
+ for (const auto &c : candidates) {
+ nhlog::ui()->debug(
+ "WebRTC: remote candidate: (m-line:{}):{}", c.sdpMLineIndex, c.candidate);
+ g_signal_emit_by_name(
+ webrtc_, "add-ice-candidate", c.sdpMLineIndex, c.candidate.c_str());
+ }
+ }
+}
+
+bool
+WebRTCSession::startPipeline(int opusPayloadType)
+{
+ if (state_ != State::DISCONNECTED)
+ return false;
+
+ emit stateChanged(State::INITIATING);
+
+ if (!createPipeline(opusPayloadType))
+ return false;
+
+ webrtc_ = gst_bin_get_by_name(GST_BIN(pipe_), "webrtcbin");
+
+ if (!stunServer_.empty()) {
+ nhlog::ui()->info("WebRTC: setting STUN server: {}", stunServer_);
+ g_object_set(webrtc_, "stun-server", stunServer_.c_str(), nullptr);
+ }
+
+ for (const auto &uri : turnServers_) {
+ nhlog::ui()->info("WebRTC: setting TURN server: {}", uri);
+ gboolean udata;
+ g_signal_emit_by_name(webrtc_, "add-turn-server", uri.c_str(), (gpointer)(&udata));
+ }
+ if (turnServers_.empty())
+ nhlog::ui()->warn("WebRTC: no TURN server provided");
+
+ // generate the offer when the pipeline goes to PLAYING
+ if (isoffering_)
+ g_signal_connect(
+ webrtc_, "on-negotiation-needed", G_CALLBACK(::createOffer), nullptr);
+
+ // on-ice-candidate is emitted when a local ICE candidate has been gathered
+ g_signal_connect(webrtc_, "on-ice-candidate", G_CALLBACK(addLocalICECandidate), nullptr);
+
+ // capture ICE failure
+ g_signal_connect(
+ webrtc_, "notify::ice-connection-state", G_CALLBACK(iceConnectionStateChanged), nullptr);
+
+ // incoming streams trigger pad-added
+ gst_element_set_state(pipe_, GST_STATE_READY);
+ g_signal_connect(webrtc_, "pad-added", G_CALLBACK(addDecodeBin), pipe_);
+
+#if GST_CHECK_VERSION(1, 17, 0)
+ // capture ICE gathering completion
+ g_signal_connect(
+ webrtc_, "notify::ice-gathering-state", G_CALLBACK(iceGatheringStateChanged), nullptr);
+#endif
+ // webrtcbin lifetime is the same as that of the pipeline
+ gst_object_unref(webrtc_);
+
+ // start the pipeline
+ GstStateChangeReturn ret = gst_element_set_state(pipe_, GST_STATE_PLAYING);
+ if (ret == GST_STATE_CHANGE_FAILURE) {
+ nhlog::ui()->error("WebRTC: unable to start pipeline");
+ end();
+ return false;
+ }
+
+ GstBus *bus = gst_pipeline_get_bus(GST_PIPELINE(pipe_));
+ gst_bus_add_watch(bus, newBusMessage, this);
+ gst_object_unref(bus);
+ emit stateChanged(State::INITIATED);
+ return true;
+}
+
+bool
+WebRTCSession::createPipeline(int opusPayloadType)
+{
+ int nSources = audioSources_ ? g_list_length(audioSources_) : 0;
+ if (nSources == 0) {
+ nhlog::ui()->error("WebRTC: no audio sources");
+ return false;
+ }
+
+ if (audioSourceIndex_ < 0 || audioSourceIndex_ >= nSources) {
+ nhlog::ui()->error("WebRTC: invalid audio source index");
+ return false;
+ }
+
+ GstElement *source = gst_device_create_element(
+ GST_DEVICE_CAST(g_list_nth_data(audioSources_, audioSourceIndex_)), nullptr);
+ GstElement *volume = gst_element_factory_make("volume", "srclevel");
+ GstElement *convert = gst_element_factory_make("audioconvert", nullptr);
+ GstElement *resample = gst_element_factory_make("audioresample", nullptr);
+ GstElement *queue1 = gst_element_factory_make("queue", nullptr);
+ GstElement *opusenc = gst_element_factory_make("opusenc", nullptr);
+ GstElement *rtp = gst_element_factory_make("rtpopuspay", nullptr);
+ GstElement *queue2 = gst_element_factory_make("queue", nullptr);
+ GstElement *capsfilter = gst_element_factory_make("capsfilter", nullptr);
+
+ GstCaps *rtpcaps = gst_caps_new_simple("application/x-rtp",
+ "media",
+ G_TYPE_STRING,
+ "audio",
+ "encoding-name",
+ G_TYPE_STRING,
+ "OPUS",
+ "payload",
+ G_TYPE_INT,
+ opusPayloadType,
+ nullptr);
+ g_object_set(capsfilter, "caps", rtpcaps, nullptr);
+ gst_caps_unref(rtpcaps);
+
+ GstElement *webrtcbin = gst_element_factory_make("webrtcbin", "webrtcbin");
+ g_object_set(webrtcbin, "bundle-policy", GST_WEBRTC_BUNDLE_POLICY_MAX_BUNDLE, nullptr);
+
+ pipe_ = gst_pipeline_new(nullptr);
+ gst_bin_add_many(GST_BIN(pipe_),
+ source,
+ volume,
+ convert,
+ resample,
+ queue1,
+ opusenc,
+ rtp,
+ queue2,
+ capsfilter,
+ webrtcbin,
+ nullptr);
+
+ if (!gst_element_link_many(source,
+ volume,
+ convert,
+ resample,
+ queue1,
+ opusenc,
+ rtp,
+ queue2,
+ capsfilter,
+ webrtcbin,
+ nullptr)) {
+ nhlog::ui()->error("WebRTC: failed to link pipeline elements");
+ end();
+ return false;
+ }
+ return true;
+}
+
+bool
+WebRTCSession::toggleMuteAudioSrc(bool &isMuted)
+{
+ if (state_ < State::INITIATED)
+ return false;
+
+ GstElement *srclevel = gst_bin_get_by_name(GST_BIN(pipe_), "srclevel");
+ if (!srclevel)
+ return false;
+
+ gboolean muted;
+ g_object_get(srclevel, "mute", &muted, nullptr);
+ g_object_set(srclevel, "mute", !muted, nullptr);
+ gst_object_unref(srclevel);
+ isMuted = !muted;
+ return true;
+}
+
+void
+WebRTCSession::end()
+{
+ nhlog::ui()->debug("WebRTC: ending session");
+ if (pipe_) {
+ gst_element_set_state(pipe_, GST_STATE_NULL);
+ gst_object_unref(pipe_);
+ pipe_ = nullptr;
+ }
+ webrtc_ = nullptr;
+ if (state_ != State::DISCONNECTED)
+ emit stateChanged(State::DISCONNECTED);
+}
+
+void
+WebRTCSession::refreshDevices()
+{
+ if (!initialised_)
+ return;
+
+ static GstDeviceMonitor *monitor = nullptr;
+ if (!monitor) {
+ monitor = gst_device_monitor_new();
+ GstCaps *caps = gst_caps_new_empty_simple("audio/x-raw");
+ gst_device_monitor_add_filter(monitor, "Audio/Source", caps);
+ gst_caps_unref(caps);
+ }
+ g_list_free_full(audioSources_, g_object_unref);
+ audioSources_ = gst_device_monitor_get_devices(monitor);
+}
+
+std::vector<std::string>
+WebRTCSession::getAudioSourceNames(const std::string &defaultDevice)
+{
+ if (!initialised_)
+ return {};
+
+ refreshDevices();
+ std::vector<std::string> ret;
+ ret.reserve(g_list_length(audioSources_));
+ for (GList *l = audioSources_; l != nullptr; l = l->next) {
+ gchar *name = gst_device_get_display_name(GST_DEVICE_CAST(l->data));
+ ret.emplace_back(name);
+ g_free(name);
+ if (ret.back() == defaultDevice) {
+ // move default device to top of the list
+ std::swap(audioSources_->data, l->data);
+ std::swap(ret.front(), ret.back());
+ }
+ }
+ return ret;
+}
+#else
+
+bool
+WebRTCSession::createOffer()
+{
+ return false;
+}
+
+bool
+WebRTCSession::acceptOffer(const std::string &)
+{
+ return false;
+}
+
+bool
+WebRTCSession::acceptAnswer(const std::string &)
+{
+ return false;
+}
+
+void
+WebRTCSession::acceptICECandidates(const std::vector<mtx::events::msg::CallCandidates::Candidate> &)
+{}
+
+bool
+WebRTCSession::startPipeline(int)
+{
+ return false;
+}
+
+bool
+WebRTCSession::createPipeline(int)
+{
+ return false;
+}
+
+bool
+WebRTCSession::toggleMuteAudioSrc(bool &)
+{
+ return false;
+}
+
+void
+WebRTCSession::end()
+{}
+
+void
+WebRTCSession::refreshDevices()
+{}
+
+std::vector<std::string>
+WebRTCSession::getAudioSourceNames(const std::string &)
+{
+ return {};
+}
+
+#endif
diff --git a/src/WebRTCSession.h b/src/WebRTCSession.h
new file mode 100644
index 00000000..56d76fa8
--- /dev/null
+++ b/src/WebRTCSession.h
@@ -0,0 +1,83 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <QObject>
+
+#include "mtx/events/voip.hpp"
+
+typedef struct _GList GList;
+typedef struct _GstElement GstElement;
+
+class WebRTCSession : public QObject
+{
+ Q_OBJECT
+
+public:
+ enum class State
+ {
+ DISCONNECTED,
+ ICEFAILED,
+ INITIATING,
+ INITIATED,
+ OFFERSENT,
+ ANSWERSENT,
+ CONNECTING,
+ CONNECTED
+ };
+
+ static WebRTCSession &instance()
+ {
+ static WebRTCSession instance;
+ return instance;
+ }
+
+ bool init(std::string *errorMessage = nullptr);
+ State state() const { return state_; }
+
+ bool createOffer();
+ bool acceptOffer(const std::string &sdp);
+ bool acceptAnswer(const std::string &sdp);
+ void acceptICECandidates(const std::vector<mtx::events::msg::CallCandidates::Candidate> &);
+
+ bool toggleMuteAudioSrc(bool &isMuted);
+ void end();
+
+ void setStunServer(const std::string &stunServer) { stunServer_ = stunServer; }
+ void setTurnServers(const std::vector<std::string> &uris) { turnServers_ = uris; }
+
+ std::vector<std::string> getAudioSourceNames(const std::string &defaultDevice);
+ void setAudioSource(int audioDeviceIndex) { audioSourceIndex_ = audioDeviceIndex; }
+
+signals:
+ void offerCreated(const std::string &sdp,
+ const std::vector<mtx::events::msg::CallCandidates::Candidate> &);
+ void answerCreated(const std::string &sdp,
+ const std::vector<mtx::events::msg::CallCandidates::Candidate> &);
+ void newICECandidate(const mtx::events::msg::CallCandidates::Candidate &);
+ void stateChanged(WebRTCSession::State); // explicit qualifier necessary for Qt
+
+private slots:
+ void setState(State state) { state_ = state; }
+
+private:
+ WebRTCSession();
+
+ bool initialised_ = false;
+ State state_ = State::DISCONNECTED;
+ GstElement *pipe_ = nullptr;
+ GstElement *webrtc_ = nullptr;
+ std::string stunServer_;
+ std::vector<std::string> turnServers_;
+ GList *audioSources_ = nullptr;
+ int audioSourceIndex_ = -1;
+
+ bool startPipeline(int opusPayloadType);
+ bool createPipeline(int opusPayloadType);
+ void refreshDevices();
+
+public:
+ WebRTCSession(WebRTCSession const &) = delete;
+ void operator=(WebRTCSession const &) = delete;
+};
diff --git a/src/dialogs/AcceptCall.cpp b/src/dialogs/AcceptCall.cpp
new file mode 100644
index 00000000..2b47b7dc
--- /dev/null
+++ b/src/dialogs/AcceptCall.cpp
@@ -0,0 +1,134 @@
+#include <QComboBox>
+#include <QLabel>
+#include <QPushButton>
+#include <QString>
+#include <QVBoxLayout>
+
+#include "ChatPage.h"
+#include "Config.h"
+#include "UserSettingsPage.h"
+#include "Utils.h"
+#include "WebRTCSession.h"
+#include "dialogs/AcceptCall.h"
+#include "ui/Avatar.h"
+
+namespace dialogs {
+
+AcceptCall::AcceptCall(const QString &caller,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl,
+ QSharedPointer<UserSettings> settings,
+ QWidget *parent)
+ : QWidget(parent)
+{
+ std::string errorMessage;
+ if (!WebRTCSession::instance().init(&errorMessage)) {
+ emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
+ emit close();
+ return;
+ }
+ audioDevices_ = WebRTCSession::instance().getAudioSourceNames(
+ settings->defaultAudioSource().toStdString());
+ if (audioDevices_.empty()) {
+ emit ChatPage::instance()->showNotification(
+ "Incoming call: No audio sources found.");
+ emit close();
+ return;
+ }
+
+ setAutoFillBackground(true);
+ setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
+ setWindowModality(Qt::WindowModal);
+ setAttribute(Qt::WA_DeleteOnClose, true);
+
+ setMinimumWidth(conf::modals::MIN_WIDGET_WIDTH);
+ setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+
+ auto layout = new QVBoxLayout(this);
+ layout->setSpacing(conf::modals::WIDGET_SPACING);
+ layout->setMargin(conf::modals::WIDGET_MARGIN);
+
+ QFont f;
+ f.setPointSizeF(f.pointSizeF());
+
+ QFont labelFont;
+ labelFont.setWeight(QFont::Medium);
+
+ QLabel *displayNameLabel = nullptr;
+ if (!displayName.isEmpty() && displayName != caller) {
+ displayNameLabel = new QLabel(displayName, this);
+ labelFont.setPointSizeF(f.pointSizeF() * 2);
+ displayNameLabel->setFont(labelFont);
+ displayNameLabel->setAlignment(Qt::AlignCenter);
+ }
+
+ QLabel *callerLabel = new QLabel(caller, this);
+ labelFont.setPointSizeF(f.pointSizeF() * 1.2);
+ callerLabel->setFont(labelFont);
+ callerLabel->setAlignment(Qt::AlignCenter);
+
+ auto avatar = new Avatar(this, QFontMetrics(f).height() * 6);
+ if (!avatarUrl.isEmpty())
+ avatar->setImage(avatarUrl);
+ else
+ avatar->setLetter(utils::firstChar(roomName));
+
+ const int iconSize = 22;
+ QLabel *callTypeIndicator = new QLabel(this);
+ callTypeIndicator->setPixmap(
+ QIcon(":/icons/icons/ui/place-call.png").pixmap(QSize(iconSize * 2, iconSize * 2)));
+
+ QLabel *callTypeLabel = new QLabel("Voice Call", this);
+ labelFont.setPointSizeF(f.pointSizeF() * 1.1);
+ callTypeLabel->setFont(labelFont);
+ callTypeLabel->setAlignment(Qt::AlignCenter);
+
+ auto buttonLayout = new QHBoxLayout;
+ buttonLayout->setSpacing(18);
+ acceptBtn_ = new QPushButton(tr("Accept"), this);
+ acceptBtn_->setDefault(true);
+ acceptBtn_->setIcon(QIcon(":/icons/icons/ui/place-call.png"));
+ acceptBtn_->setIconSize(QSize(iconSize, iconSize));
+
+ rejectBtn_ = new QPushButton(tr("Reject"), this);
+ rejectBtn_->setIcon(QIcon(":/icons/icons/ui/end-call.png"));
+ rejectBtn_->setIconSize(QSize(iconSize, iconSize));
+ buttonLayout->addWidget(acceptBtn_);
+ buttonLayout->addWidget(rejectBtn_);
+
+ auto deviceLayout = new QHBoxLayout;
+ auto audioLabel = new QLabel(this);
+ audioLabel->setPixmap(
+ QIcon(":/icons/icons/ui/microphone-unmute.png").pixmap(QSize(iconSize, iconSize)));
+
+ auto deviceList = new QComboBox(this);
+ for (const auto &d : audioDevices_)
+ deviceList->addItem(QString::fromStdString(d));
+
+ deviceLayout->addStretch();
+ deviceLayout->addWidget(audioLabel);
+ deviceLayout->addWidget(deviceList);
+
+ if (displayNameLabel)
+ layout->addWidget(displayNameLabel, 0, Qt::AlignCenter);
+ layout->addWidget(callerLabel, 0, Qt::AlignCenter);
+ layout->addWidget(avatar, 0, Qt::AlignCenter);
+ layout->addWidget(callTypeIndicator, 0, Qt::AlignCenter);
+ layout->addWidget(callTypeLabel, 0, Qt::AlignCenter);
+ layout->addLayout(buttonLayout);
+ layout->addLayout(deviceLayout);
+
+ connect(acceptBtn_, &QPushButton::clicked, this, [this, deviceList, settings]() {
+ WebRTCSession::instance().setAudioSource(deviceList->currentIndex());
+ settings->setDefaultAudioSource(
+ QString::fromStdString(audioDevices_[deviceList->currentIndex()]));
+ emit accept();
+ emit close();
+ });
+ connect(rejectBtn_, &QPushButton::clicked, this, [this]() {
+ emit reject();
+ emit close();
+ });
+}
+}
diff --git a/src/dialogs/AcceptCall.h b/src/dialogs/AcceptCall.h
new file mode 100644
index 00000000..5db8fcfa
--- /dev/null
+++ b/src/dialogs/AcceptCall.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <QSharedPointer>
+#include <QWidget>
+
+class QPushButton;
+class QString;
+class UserSettings;
+
+namespace dialogs {
+
+class AcceptCall : public QWidget
+{
+ Q_OBJECT
+
+public:
+ AcceptCall(const QString &caller,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl,
+ QSharedPointer<UserSettings> settings,
+ QWidget *parent = nullptr);
+
+signals:
+ void accept();
+ void reject();
+
+private:
+ QPushButton *acceptBtn_;
+ QPushButton *rejectBtn_;
+ std::vector<std::string> audioDevices_;
+};
+}
diff --git a/src/dialogs/PlaceCall.cpp b/src/dialogs/PlaceCall.cpp
new file mode 100644
index 00000000..8acdbe88
--- /dev/null
+++ b/src/dialogs/PlaceCall.cpp
@@ -0,0 +1,103 @@
+#include <QComboBox>
+#include <QLabel>
+#include <QPushButton>
+#include <QString>
+#include <QVBoxLayout>
+
+#include "ChatPage.h"
+#include "Config.h"
+#include "UserSettingsPage.h"
+#include "Utils.h"
+#include "WebRTCSession.h"
+#include "dialogs/PlaceCall.h"
+#include "ui/Avatar.h"
+
+namespace dialogs {
+
+PlaceCall::PlaceCall(const QString &callee,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl,
+ QSharedPointer<UserSettings> settings,
+ QWidget *parent)
+ : QWidget(parent)
+{
+ std::string errorMessage;
+ if (!WebRTCSession::instance().init(&errorMessage)) {
+ emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage));
+ emit close();
+ return;
+ }
+ audioDevices_ = WebRTCSession::instance().getAudioSourceNames(
+ settings->defaultAudioSource().toStdString());
+ if (audioDevices_.empty()) {
+ emit ChatPage::instance()->showNotification("No audio sources found.");
+ emit close();
+ return;
+ }
+
+ setAutoFillBackground(true);
+ setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
+ setWindowModality(Qt::WindowModal);
+ setAttribute(Qt::WA_DeleteOnClose, true);
+
+ auto layout = new QVBoxLayout(this);
+ layout->setSpacing(conf::modals::WIDGET_SPACING);
+ layout->setMargin(conf::modals::WIDGET_MARGIN);
+
+ auto buttonLayout = new QHBoxLayout;
+ buttonLayout->setSpacing(15);
+ buttonLayout->setMargin(0);
+
+ QFont f;
+ f.setPointSizeF(f.pointSizeF());
+ auto avatar = new Avatar(this, QFontMetrics(f).height() * 3);
+ if (!avatarUrl.isEmpty())
+ avatar->setImage(avatarUrl);
+ else
+ avatar->setLetter(utils::firstChar(roomName));
+ const int iconSize = 18;
+ voiceBtn_ = new QPushButton(tr("Voice"), this);
+ voiceBtn_->setIcon(QIcon(":/icons/icons/ui/place-call.png"));
+ voiceBtn_->setIconSize(QSize(iconSize, iconSize));
+ voiceBtn_->setDefault(true);
+ cancelBtn_ = new QPushButton(tr("Cancel"), this);
+
+ buttonLayout->addWidget(avatar);
+ buttonLayout->addStretch();
+ buttonLayout->addWidget(voiceBtn_);
+ buttonLayout->addWidget(cancelBtn_);
+
+ QString name = displayName.isEmpty() ? callee : displayName;
+ QLabel *label = new QLabel("Place a call to " + name + "?", this);
+
+ auto deviceLayout = new QHBoxLayout;
+ auto audioLabel = new QLabel(this);
+ audioLabel->setPixmap(QIcon(":/icons/icons/ui/microphone-unmute.png")
+ .pixmap(QSize(iconSize * 1.2, iconSize * 1.2)));
+
+ auto deviceList = new QComboBox(this);
+ for (const auto &d : audioDevices_)
+ deviceList->addItem(QString::fromStdString(d));
+
+ deviceLayout->addStretch();
+ deviceLayout->addWidget(audioLabel);
+ deviceLayout->addWidget(deviceList);
+
+ layout->addWidget(label);
+ layout->addLayout(buttonLayout);
+ layout->addLayout(deviceLayout);
+
+ connect(voiceBtn_, &QPushButton::clicked, this, [this, deviceList, settings]() {
+ WebRTCSession::instance().setAudioSource(deviceList->currentIndex());
+ settings->setDefaultAudioSource(
+ QString::fromStdString(audioDevices_[deviceList->currentIndex()]));
+ emit voice();
+ emit close();
+ });
+ connect(cancelBtn_, &QPushButton::clicked, this, [this]() {
+ emit cancel();
+ emit close();
+ });
+}
+}
diff --git a/src/dialogs/PlaceCall.h b/src/dialogs/PlaceCall.h
new file mode 100644
index 00000000..e178afc4
--- /dev/null
+++ b/src/dialogs/PlaceCall.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include <QSharedPointer>
+#include <QWidget>
+
+class QPushButton;
+class QString;
+class UserSettings;
+
+namespace dialogs {
+
+class PlaceCall : public QWidget
+{
+ Q_OBJECT
+
+public:
+ PlaceCall(const QString &callee,
+ const QString &displayName,
+ const QString &roomName,
+ const QString &avatarUrl,
+ QSharedPointer<UserSettings> settings,
+ QWidget *parent = nullptr);
+
+signals:
+ void voice();
+ void cancel();
+
+private:
+ QPushButton *voiceBtn_;
+ QPushButton *cancelBtn_;
+ std::vector<std::string> audioDevices_;
+};
+}
diff --git a/src/dialogs/UserProfile.cpp b/src/dialogs/UserProfile.cpp
new file mode 100644
index 00000000..086dbb40
--- /dev/null
+++ b/src/dialogs/UserProfile.cpp
@@ -0,0 +1,316 @@
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QListWidget>
+#include <QMessageBox>
+#include <QShortcut>
+#include <QVBoxLayout>
+
+#include "Cache.h"
+#include "ChatPage.h"
+#include "Logging.h"
+#include "MatrixClient.h"
+#include "Utils.h"
+#include "dialogs/UserProfile.h"
+#include "ui/Avatar.h"
+#include "ui/FlatButton.h"
+
+using namespace dialogs;
+
+Q_DECLARE_METATYPE(std::vector<DeviceInfo>)
+
+constexpr int BUTTON_SIZE = 36;
+constexpr int BUTTON_RADIUS = BUTTON_SIZE / 2;
+constexpr int WIDGET_MARGIN = 20;
+constexpr int TOP_WIDGET_MARGIN = 2 * WIDGET_MARGIN;
+constexpr int WIDGET_SPACING = 15;
+constexpr int TEXT_SPACING = 4;
+constexpr int DEVICE_SPACING = 5;
+
+DeviceItem::DeviceItem(DeviceInfo device, QWidget *parent)
+ : QWidget(parent)
+ , info_(std::move(device))
+{
+ QFont font;
+ font.setBold(true);
+
+ auto deviceIdLabel = new QLabel(info_.device_id, this);
+ deviceIdLabel->setFont(font);
+
+ auto layout = new QVBoxLayout{this};
+ layout->addWidget(deviceIdLabel);
+
+ if (!info_.display_name.isEmpty())
+ layout->addWidget(new QLabel(info_.display_name, this));
+
+ layout->setMargin(0);
+ layout->setSpacing(4);
+}
+
+UserProfile::UserProfile(QWidget *parent)
+ : QWidget(parent)
+{
+ setAutoFillBackground(true);
+ setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
+ setAttribute(Qt::WA_DeleteOnClose, true);
+
+ QIcon banIcon, kickIcon, ignoreIcon, startChatIcon;
+
+ banIcon.addFile(":/icons/icons/ui/do-not-disturb-rounded-sign.png");
+ banBtn_ = new FlatButton(this);
+ banBtn_->setFixedSize(BUTTON_SIZE, BUTTON_SIZE);
+ banBtn_->setCornerRadius(BUTTON_RADIUS);
+ banBtn_->setIcon(banIcon);
+ banBtn_->setIconSize(QSize(BUTTON_RADIUS, BUTTON_RADIUS));
+ banBtn_->setToolTip(tr("Ban the user from the room"));
+
+ ignoreIcon.addFile(":/icons/icons/ui/volume-off-indicator.png");
+ ignoreBtn_ = new FlatButton(this);
+ ignoreBtn_->setFixedSize(BUTTON_SIZE, BUTTON_SIZE);
+ ignoreBtn_->setCornerRadius(BUTTON_RADIUS);
+ ignoreBtn_->setIcon(ignoreIcon);
+ ignoreBtn_->setIconSize(QSize(BUTTON_RADIUS, BUTTON_RADIUS));
+ ignoreBtn_->setToolTip(tr("Ignore messages from this user"));
+ ignoreBtn_->setDisabled(true); // Not used yet.
+
+ kickIcon.addFile(":/icons/icons/ui/round-remove-button.png");
+ kickBtn_ = new FlatButton(this);
+ kickBtn_->setFixedSize(BUTTON_SIZE, BUTTON_SIZE);
+ kickBtn_->setCornerRadius(BUTTON_RADIUS);
+ kickBtn_->setIcon(kickIcon);
+ kickBtn_->setIconSize(QSize(BUTTON_RADIUS, BUTTON_RADIUS));
+ kickBtn_->setToolTip(tr("Kick the user from the room"));
+
+ startChatIcon.addFile(":/icons/icons/ui/black-bubble-speech.png");
+ startChat_ = new FlatButton(this);
+ startChat_->setFixedSize(BUTTON_SIZE, BUTTON_SIZE);
+ startChat_->setCornerRadius(BUTTON_RADIUS);
+ startChat_->setIcon(startChatIcon);
+ startChat_->setIconSize(QSize(BUTTON_RADIUS, BUTTON_RADIUS));
+ startChat_->setToolTip(tr("Start a conversation"));
+
+ connect(startChat_, &QPushButton::clicked, this, [this]() {
+ auto user_id = userIdLabel_->text();
+
+ mtx::requests::CreateRoom req;
+ req.preset = mtx::requests::Preset::PrivateChat;
+ req.visibility = mtx::requests::Visibility::Private;
+
+ if (utils::localUser() != user_id)
+ req.invite = {user_id.toStdString()};
+
+ if (QMessageBox::question(
+ this,
+ tr("Confirm DM"),
+ tr("Do you really want to invite %1 (%2) to a direct chat?")
+ .arg(cache::displayName(roomId_, user_id))
+ .arg(user_id)) != QMessageBox::Yes)
+ return;
+
+ emit ChatPage::instance()->createRoom(req);
+ });
+
+ connect(banBtn_, &QPushButton::clicked, this, [this] {
+ ChatPage::instance()->banUser(userIdLabel_->text(), "");
+ });
+ connect(kickBtn_, &QPushButton::clicked, this, [this] {
+ ChatPage::instance()->kickUser(userIdLabel_->text(), "");
+ });
+
+ // Button line
+ auto btnLayout = new QHBoxLayout;
+ btnLayout->addStretch(1);
+ btnLayout->addWidget(startChat_);
+ btnLayout->addWidget(ignoreBtn_);
+
+ btnLayout->addWidget(kickBtn_);
+ btnLayout->addWidget(banBtn_);
+ btnLayout->addStretch(1);
+ btnLayout->setSpacing(8);
+ btnLayout->setMargin(0);
+
+ avatar_ = new Avatar(this, 128);
+ avatar_->setLetter("X");
+
+ QFont font;
+ font.setPointSizeF(font.pointSizeF() * 2);
+
+ userIdLabel_ = new QLabel(this);
+ displayNameLabel_ = new QLabel(this);
+ displayNameLabel_->setFont(font);
+
+ auto textLayout = new QVBoxLayout;
+ textLayout->addWidget(displayNameLabel_);
+ textLayout->addWidget(userIdLabel_);
+ textLayout->setAlignment(displayNameLabel_, Qt::AlignCenter | Qt::AlignTop);
+ textLayout->setAlignment(userIdLabel_, Qt::AlignCenter | Qt::AlignTop);
+ textLayout->setSpacing(TEXT_SPACING);
+ textLayout->setMargin(0);
+
+ devices_ = new QListWidget{this};
+ devices_->setFrameStyle(QFrame::NoFrame);
+ devices_->setSelectionMode(QAbstractItemView::NoSelection);
+ devices_->setAttribute(Qt::WA_MacShowFocusRect, 0);
+ devices_->setSpacing(DEVICE_SPACING);
+
+ QFont descriptionLabelFont;
+ descriptionLabelFont.setWeight(65);
+
+ devicesLabel_ = new QLabel(tr("Devices").toUpper(), this);
+ devicesLabel_->setFont(descriptionLabelFont);
+ devicesLabel_->hide();
+ devicesLabel_->setFixedSize(devicesLabel_->sizeHint());
+
+ auto okBtn = new QPushButton("OK", this);
+
+ auto closeLayout = new QHBoxLayout();
+ closeLayout->setSpacing(15);
+ closeLayout->addStretch(1);
+ closeLayout->addWidget(okBtn);
+
+ auto vlayout = new QVBoxLayout{this};
+ vlayout->addWidget(avatar_, 0, Qt::AlignCenter | Qt::AlignTop);
+ vlayout->addLayout(textLayout);
+ vlayout->addLayout(btnLayout);
+ vlayout->addWidget(devicesLabel_, 0, Qt::AlignLeft);
+ vlayout->addWidget(devices_, 1);
+ vlayout->addLayout(closeLayout);
+
+ QFont largeFont;
+ largeFont.setPointSizeF(largeFont.pointSizeF() * 1.5);
+
+ setMinimumWidth(
+ std::max(devices_->sizeHint().width() + 4 * WIDGET_MARGIN, conf::window::minModalWidth));
+ setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+
+ vlayout->setSpacing(WIDGET_SPACING);
+ vlayout->setContentsMargins(WIDGET_MARGIN, TOP_WIDGET_MARGIN, WIDGET_MARGIN, WIDGET_MARGIN);
+
+ qRegisterMetaType<std::vector<DeviceInfo>>();
+
+ auto closeShortcut = new QShortcut(QKeySequence(QKeySequence::Cancel), this);
+ connect(closeShortcut, &QShortcut::activated, this, &UserProfile::close);
+ connect(okBtn, &QPushButton::clicked, this, &UserProfile::close);
+}
+
+void
+UserProfile::resetToDefaults()
+{
+ avatar_->setLetter("X");
+ devices_->clear();
+
+ ignoreBtn_->show();
+ devices_->hide();
+ devicesLabel_->hide();
+}
+
+void
+UserProfile::init(const QString &userId, const QString &roomId)
+{
+ resetToDefaults();
+
+ this->roomId_ = roomId;
+
+ auto displayName = cache::displayName(roomId, userId);
+
+ userIdLabel_->setText(userId);
+ displayNameLabel_->setText(displayName);
+ avatar_->setLetter(utils::firstChar(displayName));
+
+ avatar_->setImage(roomId, userId);
+
+ auto localUser = utils::localUser();
+
+ try {
+ bool hasMemberRights =
+ cache::hasEnoughPowerLevel({mtx::events::EventType::RoomMember},
+ roomId.toStdString(),
+ localUser.toStdString());
+ if (!hasMemberRights) {
+ kickBtn_->hide();
+ banBtn_->hide();
+ } else {
+ kickBtn_->show();
+ banBtn_->show();
+ }
+ } catch (const lmdb::error &e) {
+ nhlog::db()->warn("lmdb error: {}", e.what());
+ }
+
+ if (localUser == userId) {
+ // TODO: click on display name & avatar to change.
+ kickBtn_->hide();
+ banBtn_->hide();
+ ignoreBtn_->hide();
+ }
+
+ mtx::requests::QueryKeys req;
+ req.device_keys[userId.toStdString()] = {};
+
+ // A proxy object is used to emit the signal instead of the original object
+ // which might be destroyed by the time the http call finishes.
+ auto proxy = std::make_shared<Proxy>();
+ QObject::connect(proxy.get(), &Proxy::done, this, &UserProfile::updateDeviceList);
+
+ http::client()->query_keys(
+ req,
+ [user_id = userId.toStdString(), proxy = std::move(proxy)](
+ const mtx::responses::QueryKeys &res, mtx::http::RequestErr err) {
+ if (err) {
+ nhlog::net()->warn("failed to query device keys: {} {}",
+ err->matrix_error.error,
+ static_cast<int>(err->status_code));
+ // TODO: Notify the UI.
+ return;
+ }
+
+ if (res.device_keys.empty() ||
+ (res.device_keys.find(user_id) == res.device_keys.end())) {
+ nhlog::net()->warn("no devices retrieved {}", user_id);
+ return;
+ }
+
+ auto devices = res.device_keys.at(user_id);
+
+ std::vector<DeviceInfo> deviceInfo;
+ for (const auto &d : devices) {
+ auto device = d.second;
+
+ // TODO: Verify signatures and ignore those that don't pass.
+ deviceInfo.emplace_back(DeviceInfo{
+ QString::fromStdString(d.first),
+ QString::fromStdString(device.unsigned_info.device_display_name)});
+ }
+
+ std::sort(deviceInfo.begin(),
+ deviceInfo.end(),
+ [](const DeviceInfo &a, const DeviceInfo &b) {
+ return a.device_id > b.device_id;
+ });
+
+ if (!deviceInfo.empty())
+ emit proxy->done(QString::fromStdString(user_id), deviceInfo);
+ });
+}
+
+void
+UserProfile::updateDeviceList(const QString &user_id, const std::vector<DeviceInfo> &devices)
+{
+ if (user_id != userIdLabel_->text())
+ return;
+
+ for (const auto &dev : devices) {
+ auto deviceItem = new DeviceItem(dev, this);
+ auto item = new QListWidgetItem;
+
+ item->setSizeHint(deviceItem->minimumSizeHint());
+ item->setFlags(Qt::NoItemFlags);
+ item->setTextAlignment(Qt::AlignCenter);
+
+ devices_->insertItem(devices_->count() - 1, item);
+ devices_->setItemWidget(item, deviceItem);
+ }
+
+ devicesLabel_->show();
+ devices_->show();
+ adjustSize();
+}
diff --git a/src/dialogs/UserProfile.h b/src/dialogs/UserProfile.h
new file mode 100644
index 00000000..8129fdcf
--- /dev/null
+++ b/src/dialogs/UserProfile.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include <QString>
+#include <QWidget>
+
+class Avatar;
+class FlatButton;
+class QLabel;
+class QListWidget;
+class Toggle;
+
+struct DeviceInfo
+{
+ QString device_id;
+ QString display_name;
+};
+
+class Proxy : public QObject
+{
+ Q_OBJECT
+
+signals:
+ void done(const QString &user_id, const std::vector<DeviceInfo> &devices);
+};
+
+namespace dialogs {
+
+class DeviceItem : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit DeviceItem(DeviceInfo device, QWidget *parent);
+
+private:
+ DeviceInfo info_;
+
+ // Toggle *verifyToggle_;
+};
+
+class UserProfile : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit UserProfile(QWidget *parent = nullptr);
+
+ void init(const QString &userId, const QString &roomId);
+
+private slots:
+ void updateDeviceList(const QString &user_id, const std::vector<DeviceInfo> &devices);
+
+private:
+ void resetToDefaults();
+
+ Avatar *avatar_;
+ QString roomId_;
+
+ QLabel *userIdLabel_;
+ QLabel *displayNameLabel_;
+
+ FlatButton *banBtn_;
+ FlatButton *kickBtn_;
+ FlatButton *ignoreBtn_;
+ FlatButton *startChat_;
+
+ QLabel *devicesLabel_;
+ QListWidget *devices_;
+};
+
+} // dialogs
diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
index 6326e98e..3ecd4c75 100644
--- a/src/timeline/EventStore.cpp
+++ b/src/timeline/EventStore.cpp
@@ -186,6 +186,26 @@ EventStore::addPending(mtx::events::collections::TimelineEvents event)
}
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())
@@ -448,36 +468,89 @@ EventStore::decryptEvent(const IdIndex &idx,
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();
-
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;
};
- try {
- if (!cache::client()->inboundMegolmSessionExists(index)) {
+ auto decryptionResult = olm::decryptEvent(index, e);
+
+ 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;
+
+ if (decryptionResult.error) {
+ 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: request megolm session_id & session_key from the sender.
- return asCacheEntry(std::move(dummy));
+ // 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;
}
- } catch (const lmdb::error &e) {
- nhlog::db()->critical("failed to check megolm session's existence: {}", e.what());
- dummy.content.body = tr("-- Decryption Error (failed to communicate with DB) --",
- "Placeholder, when the message can't be decrypted, because "
- "the DB access failed when trying to lookup the session.")
- .toStdString();
return asCacheEntry(std::move(dummy));
}
@@ -547,6 +620,11 @@ EventStore::decryptEvent(const IdIndex &idx,
"Nheko/mtxclient don't support that event type yet.")
.toStdString();
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 *
@@ -608,6 +686,12 @@ EventStore::fetchMore()
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,
diff --git a/src/timeline/EventStore.h b/src/timeline/EventStore.h
index 55a66f49..4ff4fa3b 100644
--- a/src/timeline/EventStore.h
+++ b/src/timeline/EventStore.h
@@ -104,6 +104,7 @@ signals:
public slots:
void addPending(mtx::events::collections::TimelineEvents event);
+ void clearTimeline();
private:
mtx::events::collections::TimelineEvents *decryptEvent(
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index dc5eb8cc..aea2645a 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -160,6 +160,26 @@ struct RoomEventType
{
return qml_mtx_events::EventType::Redacted;
}
+ qml_mtx_events::EventType operator()(
+ const mtx::events::Event<mtx::events::msg::CallInvite> &)
+ {
+ return qml_mtx_events::EventType::CallInvite;
+ }
+ qml_mtx_events::EventType operator()(
+ const mtx::events::Event<mtx::events::msg::CallAnswer> &)
+ {
+ return qml_mtx_events::EventType::CallAnswer;
+ }
+ qml_mtx_events::EventType operator()(
+ const mtx::events::Event<mtx::events::msg::CallHangUp> &)
+ {
+ 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; }
};
@@ -271,6 +291,7 @@ TimelineModel::roleNames() const
{RoomId, "roomId"},
{RoomName, "roomName"},
{RoomTopic, "roomTopic"},
+ {CallType, "callType"},
{Dump, "dump"},
};
}
@@ -422,6 +443,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
return QVariant(QString::fromStdString(room_name(event)));
case RoomTopic:
return QVariant(QString::fromStdString(room_topic(event)));
+ case CallType:
+ return QVariant(QString::fromStdString(call_type(event)));
case Dump: {
QVariantMap m;
auto names = roleNames();
@@ -452,6 +475,7 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
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);
}
@@ -548,8 +572,32 @@ TimelineModel::addEvents(const mtx::responses::Timeline &timeline)
events.handleSync(timeline);
- if (!timeline.events.empty())
- updateLastMessage();
+ 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 (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>
@@ -574,6 +622,23 @@ isMessage(const mtx::events::EncryptedEvent<T> &)
return true;
}
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &)
+{
+ return true;
+}
+
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &)
+{
+ return true;
+}
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &)
+{
+ return true;
+}
+
// Workaround. We also want to see a room at the top, if we just joined it
auto
isYourJoin(const mtx::events::StateEvent<mtx::events::state::Member> &e)
@@ -806,15 +871,16 @@ TimelineModel::markEventsAsRead(const std::vector<QString> &event_ids)
template<typename T>
void
-TimelineModel::sendEncryptedMessage(mtx::events::RoomEvent<T> msg)
+TimelineModel::sendEncryptedMessage(mtx::events::RoomEvent<T> msg, mtx::events::EventType eventType)
{
const auto room_id = room_id_.toStdString();
using namespace mtx::events;
using namespace mtx::identifiers;
- json doc = {
- {"type", to_string(msg.type)}, {"content", json(msg.content)}, {"room_id", room_id}};
+ json doc = {{"type", mtx::events::to_string(eventType)},
+ {"content", msg.content},
+ {"room_id", room_id}};
try {
// Check if we have already an outbound megolm session then we can use.
@@ -1093,69 +1159,115 @@ struct SendMessageVisitor
: model_(model)
{}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationRequest> &msg)
+ template<typename T, mtx::events::EventType Event>
+ void sendRoomEvent(mtx::events::RoomEvent<T> msg)
{
- model_->sendEncryptedMessage(msg);
+ if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
+ auto encInfo = mtx::accessors::file(msg);
+ if (encInfo)
+ emit model_->newEncryptedImage(encInfo.value());
+
+ model_->sendEncryptedMessage(msg, Event);
+ } else {
+ msg.type = Event;
+ emit model_->addPendingMessageToStore(msg);
+ }
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationReady> &msg)
+
+ // Do-nothing operator for all unhandled events
+ template<typename T>
+ void operator()(const mtx::events::Event<T> &)
+ {}
+
+ // 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()(mtx::events::RoomEvent<T> msg)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<T, mtx::events::EventType::RoomMessage>(msg);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationStart> &msg)
+
+ // Special operator for reactions, which are a type of m.room.message, but need to be
+ // handled distinctly for their differences from normal room messages. Specifically,
+ // 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()(mtx::events::RoomEvent<mtx::events::msg::Reaction> msg)
{
- model_->sendEncryptedMessage(msg);
+ msg.type = mtx::events::EventType::Reaction;
+ emit model_->addPendingMessageToStore(msg);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationAccept> &msg)
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &event)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<mtx::events::msg::CallInvite, mtx::events::EventType::CallInvite>(
+ event);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationMac> &msg)
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallCandidates> &event)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<mtx::events::msg::CallCandidates,
+ mtx::events::EventType::CallCandidates>(event);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationKey> &msg)
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &event)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<mtx::events::msg::CallAnswer, mtx::events::EventType::CallAnswer>(
+ event);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationDone> &msg)
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &event)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<mtx::events::msg::CallHangUp, mtx::events::EventType::CallHangUp>(
+ event);
}
- void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationCancel> &msg)
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationRequest> &msg)
{
- model_->sendEncryptedMessage(msg);
+ sendRoomEvent<mtx::events::msg::KeyVerificationRequest,
+ mtx::events::EventType::RoomMessage>(msg);
}
- // Do-nothing operator for all unhandled events
- template<typename T>
- void operator()(const mtx::events::Event<T> &)
- {}
- // 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()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationReady> &msg)
+ {
+ sendRoomEvent<mtx::events::msg::KeyVerificationReady,
+ mtx::events::EventType::KeyVerificationReady>(msg);
+ }
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationStart> &msg)
{
- if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
- auto encInfo = mtx::accessors::file(msg);
- if (encInfo)
- emit model_->newEncryptedImage(encInfo.value());
+ sendRoomEvent<mtx::events::msg::KeyVerificationStart,
+ mtx::events::EventType::KeyVerificationStart>(msg);
+ }
- model_->sendEncryptedMessage(msg);
- } else {
- emit model_->addPendingMessageToStore(msg);
- }
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationAccept> &msg)
+ {
+ sendRoomEvent<mtx::events::msg::KeyVerificationAccept,
+ mtx::events::EventType::KeyVerificationAccept>(msg);
}
- // Special operator for reactions, which are a type of m.room.message, but need to be
- // handled distinctly for their differences from normal room messages. Specifically,
- // 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()(mtx::events::RoomEvent<mtx::events::msg::Reaction> msg)
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationMac> &msg)
{
- msg.type = mtx::events::EventType::Reaction;
- emit model_->addPendingMessageToStore(msg);
+ sendRoomEvent<mtx::events::msg::KeyVerificationMac,
+ mtx::events::EventType::KeyVerificationMac>(msg);
+ }
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationKey> &msg)
+ {
+ sendRoomEvent<mtx::events::msg::KeyVerificationKey,
+ mtx::events::EventType::KeyVerificationKey>(msg);
+ }
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationDone> &msg)
+ {
+ sendRoomEvent<mtx::events::msg::KeyVerificationDone,
+ mtx::events::EventType::KeyVerificationDone>(msg);
+ }
+
+ void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationCancel> &msg)
+ {
+ sendRoomEvent<mtx::events::msg::KeyVerificationCancel,
+ mtx::events::EventType::KeyVerificationCancel>(msg);
}
TimelineModel *model_;
@@ -1173,39 +1285,6 @@ TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event)
},
event);
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationReady>>(&event)) {
- std::visit(
- [](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationReady; },
- event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationStart>>(&event)) {
- std::visit(
- [](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationStart; },
- event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationKey>>(&event)) {
- std::visit([](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationKey; },
- event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationMac>>(&event)) {
- std::visit([](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationMac; },
- event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationDone>>(&event)) {
- std::visit(
- [](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationDone; }, event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationCancel>>(&event)) {
- std::visit(
- [](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationCancel; },
- event);
- }
- if (std::get_if<mtx::events::RoomEvent<mtx::events::msg::KeyVerificationAccept>>(&event)) {
- std::visit(
- [](auto &msg) { msg.type = mtx::events::EventType::KeyVerificationAccept; },
- event);
- }
-
std::visit(SendMessageVisitor{this}, event);
}
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index fb9921d3..390fa1ed 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -37,6 +37,14 @@ enum EventType
Aliases,
/// m.room.avatar
Avatar,
+ /// m.call.invite
+ CallInvite,
+ /// m.call.answer
+ CallAnswer,
+ /// m.call.hangup
+ CallHangUp,
+ /// m.call.candidates
+ CallCandidates,
/// m.room.canonical_alias
CanonicalAlias,
/// m.room.create
@@ -173,6 +181,7 @@ public:
RoomId,
RoomName,
RoomTopic,
+ CallType,
Dump,
};
@@ -218,7 +227,7 @@ public:
void updateLastMessage();
void addEvents(const mtx::responses::Timeline &events);
template<class T>
- void sendMessage(const T &msg);
+ void sendMessageEvent(const T &content, mtx::events::EventType eventType);
RelatedInfo relatedInfo(QString id);
public slots:
@@ -251,6 +260,7 @@ public slots:
}
}
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
+ void clearTimeline() { events.clearTimeline(); }
private slots:
void addPendingMessage(mtx::events::collections::TimelineEvents event);
@@ -264,6 +274,7 @@ signals:
void typingUsersChanged(std::vector<QString> users);
void replyChanged(QString reply);
void paginationInProgressChanged(const bool);
+ void newCallEvent(const mtx::events::collections::TimelineEvents &event);
void openProfile(UserProfile *profile);
@@ -273,7 +284,7 @@ signals:
private:
template<typename T>
- void sendEncryptedMessage(mtx::events::RoomEvent<T> msg);
+ void sendEncryptedMessage(mtx::events::RoomEvent<T> msg, mtx::events::EventType eventType);
void handleClaimedKeys(std::shared_ptr<StateKeeper> keeper,
const std::map<std::string, std::string> &room_key,
const std::map<std::string, DevicePublicKeys> &pks,
@@ -304,9 +315,10 @@ private:
template<class T>
void
-TimelineModel::sendMessage(const T &msg)
+TimelineModel::sendMessageEvent(const T &content, mtx::events::EventType eventType)
{
mtx::events::RoomEvent<T> msgCopy = {};
- msgCopy.content = msg;
+ msgCopy.content = content;
+ msgCopy.type = eventType;
emit newMessageToSend(msgCopy);
}
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index fb4a094e..f199578f 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -1,11 +1,14 @@
#include "TimelineViewManager.h"
+#include <QDesktopServices>
#include <QMetaType>
#include <QPalette>
#include <QQmlContext>
#include <QQmlEngine>
+#include <QString>
#include "BlurhashProvider.h"
+#include "CallManager.h"
#include "ChatPage.h"
#include "ColorImageProvider.h"
#include "DelegateChooser.h"
@@ -97,10 +100,13 @@ TimelineViewManager::userStatus(QString id) const
return QString::fromStdString(cache::statusMessage(id.toStdString()));
}
-TimelineViewManager::TimelineViewManager(QSharedPointer<UserSettings> userSettings, QWidget *parent)
+TimelineViewManager::TimelineViewManager(QSharedPointer<UserSettings> userSettings,
+ CallManager *callManager,
+ QWidget *parent)
: imgProvider(new MxcImageProvider())
, colorImgProvider(new ColorImageProvider())
, blurhashProvider(new BlurhashProvider())
+ , callManager_(callManager)
, settings(userSettings)
{
qRegisterMetaType<mtx::events::msg::KeyVerificationAccept>();
@@ -285,6 +291,10 @@ TimelineViewManager::TimelineViewManager(QSharedPointer<UserSettings> userSettin
}
}
});
+ connect(dynamic_cast<ChatPage *>(parent), &ChatPage::loggedOut, this, [this]() {
+ isInitialSync_ = true;
+ emit initialSyncChanged(true);
+ });
}
void
@@ -294,7 +304,17 @@ TimelineViewManager::sync(const mtx::responses::Rooms &rooms)
// addRoom will only add the room, if it doesn't exist
addRoom(QString::fromStdString(room_id));
const auto &room_model = models.value(QString::fromStdString(room_id));
+ if (!isInitialSync_)
+ connect(room_model.data(),
+ &TimelineModel::newCallEvent,
+ callManager_,
+ &CallManager::syncEvent);
room_model->addEvents(room.timeline);
+ if (!isInitialSync_)
+ disconnect(room_model.data(),
+ &TimelineModel::newCallEvent,
+ callManager_,
+ &CallManager::syncEvent);
if (ChatPage::instance()->userSettings()->typingNotifications()) {
std::vector<QString> typing;
@@ -372,6 +392,12 @@ TimelineViewManager::openImageOverlay(QString mxcUrl, QString eventId) const
}
void
+TimelineViewManager::openLink(QString link) const
+{
+ QDesktopServices::openUrl(link);
+}
+
+void
TimelineViewManager::updateReadReceipts(const QString &room_id,
const std::vector<QString> &event_ids)
{
@@ -440,7 +466,7 @@ TimelineViewManager::queueTextMessage(const QString &msg)
timeline_->resetReply();
}
- timeline_->sendMessage(text);
+ timeline_->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
}
void
@@ -462,7 +488,7 @@ TimelineViewManager::queueEmoteMessage(const QString &msg)
}
if (timeline_)
- timeline_->sendMessage(emote);
+ timeline_->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
}
void
@@ -491,7 +517,7 @@ TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QSt
reaction.relates_to.event_id = reactedEvent.toStdString();
reaction.relates_to.key = reactionKey.toStdString();
- timeline_->sendMessage(reaction);
+ timeline_->sendMessageEvent(reaction, mtx::events::EventType::Reaction);
// Otherwise, we have previously reacted and the reaction should be redacted
} else {
timeline_->redactEvent(selfReactedEvent);
@@ -527,7 +553,7 @@ TimelineViewManager::queueImageMessage(const QString &roomid,
model->resetReply();
}
- model->sendMessage(image);
+ model->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
}
void
@@ -555,7 +581,7 @@ TimelineViewManager::queueFileMessage(
model->resetReply();
}
- model->sendMessage(file);
+ model->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
}
void
@@ -583,7 +609,7 @@ TimelineViewManager::queueAudioMessage(const QString &roomid,
model->resetReply();
}
- model->sendMessage(audio);
+ model->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
}
void
@@ -610,5 +636,34 @@ TimelineViewManager::queueVideoMessage(const QString &roomid,
model->resetReply();
}
- model->sendMessage(video);
+ model->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
+}
+
+void
+TimelineViewManager::queueCallMessage(const QString &roomid,
+ const mtx::events::msg::CallInvite &callInvite)
+{
+ models.value(roomid)->sendMessageEvent(callInvite, mtx::events::EventType::CallInvite);
+}
+
+void
+TimelineViewManager::queueCallMessage(const QString &roomid,
+ const mtx::events::msg::CallCandidates &callCandidates)
+{
+ models.value(roomid)->sendMessageEvent(callCandidates,
+ mtx::events::EventType::CallCandidates);
+}
+
+void
+TimelineViewManager::queueCallMessage(const QString &roomid,
+ const mtx::events::msg::CallAnswer &callAnswer)
+{
+ models.value(roomid)->sendMessageEvent(callAnswer, mtx::events::EventType::CallAnswer);
+}
+
+void
+TimelineViewManager::queueCallMessage(const QString &roomid,
+ const mtx::events::msg::CallHangUp &callHangUp)
+{
+ models.value(roomid)->sendMessageEvent(callHangUp, mtx::events::EventType::CallHangUp);
}
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index 031d07cc..19406872 100644
--- a/src/timeline/TimelineViewManager.h
+++ b/src/timeline/TimelineViewManager.h
@@ -19,6 +19,7 @@
class MxcImageProvider;
class BlurhashProvider;
+class CallManager;
class ColorImageProvider;
class UserSettings;
@@ -46,7 +47,9 @@ class TimelineViewManager : public QObject
bool isInitialSync MEMBER isInitialSync_ READ isInitialSync NOTIFY initialSyncChanged)
public:
- TimelineViewManager(QSharedPointer<UserSettings> userSettings, QWidget *parent = nullptr);
+ TimelineViewManager(QSharedPointer<UserSettings> userSettings,
+ CallManager *callManager,
+ QWidget *parent = nullptr);
QWidget *getWidget() const { return container; }
void sync(const mtx::responses::Rooms &rooms);
@@ -62,6 +65,8 @@ public:
Q_INVOKABLE QString userPresence(QString id) const;
Q_INVOKABLE QString userStatus(QString id) const;
+ Q_INVOKABLE void openLink(QString link) const;
+
signals:
void clearRoomMessageCount(QString roomid);
void updateRoomsLastMessage(QString roomid, const DescInfo &info);
@@ -110,8 +115,19 @@ public slots:
const QString &url,
const QString &mime,
uint64_t dsize);
+ void queueCallMessage(const QString &roomid, const mtx::events::msg::CallInvite &);
+ void queueCallMessage(const QString &roomid, const mtx::events::msg::CallCandidates &);
+ void queueCallMessage(const QString &roomid, const mtx::events::msg::CallAnswer &);
+ void queueCallMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
+
void updateEncryptedDescriptions();
+ void clearCurrentRoomTimeline()
+ {
+ if (timeline_)
+ timeline_->clearTimeline();
+ }
+
private:
#ifdef USE_QUICK_VIEW
QQuickView *view;
@@ -125,7 +141,8 @@ private:
BlurhashProvider *blurhashProvider;
QHash<QString, QSharedPointer<TimelineModel>> models;
- TimelineModel *timeline_ = nullptr;
+ TimelineModel *timeline_ = nullptr;
+ CallManager *callManager_ = nullptr;
bool isInitialSync_ = true;
|