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
Binary files /dev/null and b/resources/icons/ui/end-call.png differ
diff --git a/resources/icons/ui/microphone-mute.png b/resources/icons/ui/microphone-mute.png
new file mode 100644
index 00000000..0042fbe2
Binary files /dev/null and b/resources/icons/ui/microphone-mute.png differ
diff --git a/resources/icons/ui/microphone-unmute.png b/resources/icons/ui/microphone-unmute.png
new file mode 100644
index 00000000..27999c70
Binary files /dev/null and b/resources/icons/ui/microphone-unmute.png differ
diff --git a/resources/icons/ui/place-call.png b/resources/icons/ui/place-call.png
new file mode 100644
index 00000000..a820cf3f
Binary files /dev/null and b/resources/icons/ui/place-call.png differ
diff --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 @@
-
+ Search
@@ -404,6 +404,21 @@ Example: https://server.my:8787
%1 created and configured room: %2
+
+
+
+ %1 placed a %2 call.
+
+
+
+
+ %1 answered the call.
+
+
+
+
+ %1 ended the call.
+
Placeholder
@@ -1796,6 +1811,36 @@ Media size: %2
%1 sent an encrypted message
+
+
+
+ You placed a call
+
+
+
+
+ %1 placed a call
+
+
+
+
+ You answered a call
+
+
+
+
+ %1 answered a call
+
+
+
+
+ You ended a call
+
+
+
+
+ %1 ended a call
+
popups::UserMentions
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 @@
+
+
+
+
+ Cache
+
+
+
+
+
+
+
+ ChatPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CommunitiesListItem
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tag translation for m.server_notice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EditModal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EmojiPicker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EncryptionIndicator
+
+
+
+
+
+
+
+
+
+
+
+
+ InviteeItem
+
+
+
+
+
+
+
+ LoginPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MemberList
+
+
+
+
+
+
+
+
+
+
+
+
+ MessageDelegate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placeholder
+
+
+
+
+
+
+
+ QuickSwitcher
+
+
+
+
+
+
+
+ RegisterPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RoomInfo
+
+
+
+
+
+
+
+ RoomInfoListItem
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Standard matrix tag for favourites
+
+
+
+
+
+ Standard matrix tag for low priority rooms
+
+
+
+
+
+ Standard matrix tag for server notices
+
+
+
+
+
+ WhatsThis hint for tag menu actions
+
+
+
+
+
+ Add a new tag to the room
+
+
+
+
+
+ Tag name prompt title
+
+
+
+
+
+ Tag name prompt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SideBarActions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ StatusIndicator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextInputWidget
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineModel
+
+
+
+ Placeholder, when the message can't be decrypted, because the DB access failed when trying to lookup the session.
+
+
+
+
+
+ Placeholder, when the message can't be decrypted, because the DB access failed.
+
+
+
+
+
+ Placeholder, when the message can't be decrypted. In this case, the Olm decrytion returned an error, which is passed ad %1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placeholder, when the message was not decrypted yet or can't be decrypted.
+
+
+
+
+
+ Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet.
+
+
+
+
+
+ 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.)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is a leave event after the user already left and shouldn't happen apart from state resets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineRow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineView
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TopRoomBar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TrayIcon
+
+
+
+
+
+
+
+
+
+
+
+
+ UserInfoWidget
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UserSettingsPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WelcomePage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ descriptiveTime
+
+
+
+
+
+
+
+ dialogs::CreateRoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::FallbackAuth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::InviteUsers
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::JoinRoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::LeaveRoom
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::Logout
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::PreviewUploadOverlay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReCaptcha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReadReceipts
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReceiptItem
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::RoomSettings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::UserProfile
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ emoji::Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ message-description sent:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ popups::UserMentions
+
+
+
+
+
+
+
+
+
+
+
+
+ utils
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Cache
+
+
+
+
+
+
+
+ ChatPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CommunitiesListItem
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EditModal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EncryptionIndicator
+
+
+
+
+
+
+
+
+
+
+
+
+ InviteeItem
+
+
+
+
+
+
+
+ LoginPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MemberList
+
+
+
+
+
+
+
+
+
+
+
+
+ MessageDelegate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placeholder
+
+
+
+
+
+
+
+ QuickSwitcher
+
+
+
+
+
+
+
+ RegisterPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RoomInfo
+
+
+
+
+
+
+
+ RoomInfoListItem
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Standard matrix tag for favourites
+
+
+
+
+
+ Standard matrix tag for low priority rooms
+
+
+
+
+
+ Standard matrix tag for server notices
+
+
+
+
+
+ WhatsThis hint for tag menu actions
+
+
+
+
+
+ Add a new tag to the room
+
+
+
+
+
+ Tag name prompt title
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SideBarActions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ StatusIndicator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextInputWidget
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineModel
+
+
+
+ Placeholder, when the message can't be decrypted, because the DB access failed when trying to lookup the session.
+
+
+
+
+
+ Placeholder, when the message can't be decrypted, because the DB access failed.
+
+
+
+
+
+ Placeholder, when the message can't be decrypted. In this case, the Olm decrytion returned an error, which is passed ad %1.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placeholder, when the message was not decrypted yet or can't be decrypted.
+
+
+
+
+
+ Placeholder, when the message was decrypted, but we couldn't parse it, because Nheko/mtxclient don't support that event type yet.
+
+
+
+
+
+ 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.)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is a leave event after the user already left and shouldn't happen apart from state resets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineRow
+
+
+
+
+
+
+
+
+
+
+
+
+ TimelineView
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TopRoomBar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TrayIcon
+
+
+
+
+
+
+
+
+
+
+
+
+ UserInfoWidget
+
+
+
+
+
+
+
+ UserSettingsPage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WelcomePage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ descriptiveTime
+
+
+
+
+
+
+
+ dialogs::CreateRoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::FallbackAuth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::InviteUsers
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::JoinRoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::LeaveRoom
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::Logout
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::PreviewUploadOverlay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReCaptcha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReadReceipts
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::ReceiptItem
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::RoomSettings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ dialogs::UserProfile
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ emoji::Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ message-description sent:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ popups::UserMentions
+
+
+
+
+
+
+
+
+
+
+
+
+ utils
+
+
+
+
+
+
+
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
Binary files /dev/null and b/resources/media/callend.ogg differ
diff --git a/resources/media/ring.ogg b/resources/media/ring.ogg
new file mode 100644
index 00000000..708213bf
Binary files /dev/null and b/resources/media/ring.ogg differ
diff --git a/resources/media/ringback.ogg b/resources/media/ringback.ogg
new file mode 100644
index 00000000..7dbfdcd0
Binary files /dev/null and b/resources/media/ringback.ogg differ
diff --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
@@ -96,6 +96,30 @@ Item {
text: qsTr("%1 created and configured room: %2").arg(model.data.userName).arg(model.data.roomId)
}
}
+ 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
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 @@
icons/ui/mail-reply.png
+ icons/ui/place-call.png
+ icons/ui/end-call.png
+ icons/ui/microphone-mute.png
+ icons/ui/microphone-unmute.png
+
icons/emoji-categories/people.png
icons/emoji-categories/people@2x.png
icons/emoji-categories/nature.png
@@ -138,4 +143,9 @@
qml/delegates/Reply.qml
qml/device-verification/DeviceVerification.qml
+
+ media/ring.ogg
+ media/ringback.ogg
+ media/callend.ogg
+
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
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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
+
+#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 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>(&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 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();
+ 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();
+ 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
+#include
+#include
+#include
+
+#include
+#include
+
+#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)
+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
+getTurnURIs(const mtx::responses::TurnServer &turnServer);
+}
+
+CallManager::CallManager(QSharedPointer userSettings)
+ : QObject()
+ , session_(WebRTCSession::instance())
+ , turnServerTimer_(this)
+ , settings_(userSettings)
+{
+ qRegisterMetaType>();
+ qRegisterMetaType();
+ qRegisterMetaType();
+
+ connect(
+ &session_,
+ &WebRTCSession::offerCreated,
+ this,
+ [this](const std::string &sdp, const std::vector &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 &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 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_(event) || handleEvent_(event) ||
+ handleEvent_(event) || handleEvent_(event))
+ return;
+#else
+ (void)event;
+#endif
+}
+
+template
+bool
+CallManager::handleEvent_(const mtx::events::collections::TimelineEvents &event)
+{
+ if (std::holds_alternative>(event)) {
+ handleEvent(std::get>(event));
+ return true;
+ }
+ return false;
+}
+
+void
+CallManager::handleEvent(const RoomEvent &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 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 &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 &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 &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(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
+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 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
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#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);
+
+ 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 remoteICECandidates_;
+ std::vector turnURIs_;
+ QTimer turnServerTimer_;
+ QSharedPointer settings_;
+ QMediaPlayer player_;
+
+ template
+ bool handleEvent_(const mtx::events::collections::TimelineEvents &event);
+ void handleEvent(const mtx::events::RoomEvent &);
+ void handleEvent(const mtx::events::RoomEvent &);
+ void handleEvent(const mtx::events::RoomEvent &);
+ void handleEvent(const mtx::events::RoomEvent &);
+ 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
#include
+#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, QWidget *parent)
, isConnected_(true)
, userSettings_{userSettings}
, notificationsManager(this)
+ , callManager_(userSettings)
{
setObjectName("chatPage");
@@ -124,11 +128,17 @@ ChatPage::ChatPage(QSharedPointer 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, 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, 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 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, QWidget *parent)
connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage);
+ connectCallMessage();
+ connectCallMessage();
+ connectCallMessage();
+ connectCallMessage();
+
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
+void
+ChatPage::connectCallMessage()
+{
+ connect(&callManager_,
+ qOverload(&CallManager::newMessage),
+ view_manager_,
+ qOverload(&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
#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
+ 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_;
NotificationsManager notificationsManager;
+ CallManager callManager_;
};
template
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 = "\\1";
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<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
+#include
#include
namespace {
@@ -72,6 +74,29 @@ struct EventRoomTopic
}
};
+struct CallType
+{
+ template
+ std::string operator()(const T &e)
+ {
+ if constexpr (std::is_same_v,
+ 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
@@ -353,6 +378,12 @@ mtx::accessors::room_topic(const mtx::events::collections::TimelineEvents &event
return std::visit(EventRoomTopic{}, 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)
{
diff --git a/src/EventAccessors.h b/src/EventAccessors.h
index 8f08ef1c..0cdc5f89 100644
--- a/src/EventAccessors.h
+++ b/src/EventAccessors.h
@@ -30,6 +30,9 @@ room_name(const mtx::events::collections::TimelineEvents &event);
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);
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
#include
+#include
#include
#include
#include
@@ -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> body;
+ body[mtx::identifiers::parse(e.sender)][e.content.device_id] =
+ request;
+ body[http::client()->user_id()]["*"] = request;
- nhlog::net()->info(
- "m.room_key_request sent to {}:{}", e.sender, e.content.device_id);
- });
+ http::client()->send_to_device(
+ http::client()->generate_txn_id(), body, [e](mtx::http::RequestErr err) {
+ if (err) {
+ nhlog::net()->warn("failed to send "
+ "send_to_device "
+ "message: {}",
+ err->matrix_error.error);
+ }
+
+ nhlog::net()->info("m.room_key_request sent to {}:{} and your own devices",
+ e.sender,
+ e.content.device_id);
+ });
}
void
@@ -610,4 +615,50 @@ send_megolm_key_to_device(const std::string &user_id,
});
}
+DecryptionResult
+decryptEvent(const MegolmSessionIndex &index,
+ const mtx::events::EncryptedEvent &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
#include
+#include
+
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 error;
+ std::optional error_message;
+ std::optional 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 &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
#include
+#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 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();
+ useStunServer_ = settings.value("user/use_stun_server", false).toBool();
+ defaultAudioSource_ = settings.value("user/default_audio_source", QString()).toString();
applyTheme();
}
@@ -279,6 +281,26 @@ UserSettings::setTheme(QString theme)
emit themeChanged(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()
{
@@ -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 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 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 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 settings, QWidge
settings_->setEnlargeEmojiOnlyMessages(!disabled);
});
+ connect(useStunServer_, &Toggle::toggled, this, [this](bool disabled) {
+ settings_->setUseStunServer(!disabled);
+ });
+
connect(timelineMaxWidthSpin_,
qOverload(&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(
- 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(
+ 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;
- using Emote = mtx::events::RoomEvent;
- using File = mtx::events::RoomEvent;
- using Image = mtx::events::RoomEvent;
- using Notice = mtx::events::RoomEvent;
- using Text = mtx::events::RoomEvent;
- using Video = mtx::events::RoomEvent;
- using Encrypted = mtx::events::EncryptedEvent;
+ using Audio = mtx::events::RoomEvent;
+ using Emote = mtx::events::RoomEvent;
+ using File = mtx::events::RoomEvent;
+ using Image = mtx::events::RoomEvent;
+ using Notice = mtx::events::RoomEvent;
+ using Text = mtx::events::RoomEvent;
+ using Video = mtx::events::RoomEvent;
+ using CallInvite = mtx::events::RoomEvent;
+ using CallAnswer = mtx::events::RoomEvent;
+ using CallHangUp = mtx::events::RoomEvent;
+ using Encrypted = mtx::events::EncryptedEvent;
if (std::holds_alternative