diff --git a/CMakeLists.txt b/CMakeLists.txt index f1ccde5f..1be11fa3 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 @@ -276,9 +278,11 @@ set(SRC_FILES src/ui/Theme.cpp src/ui/ThemeManager.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 @@ -304,6 +308,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 @@ -335,7 +340,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG eddd95a896fad0c51fc800741d82bbc43fc6d41e + GIT_TAG 744018c86a8094acbda9821d6d7b5a890d4aac47 ) FetchContent_MakeAvailable(MatrixClient) else() @@ -421,6 +426,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/) @@ -429,6 +437,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 @@ -437,6 +446,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 @@ -480,9 +490,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 @@ -502,6 +514,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 @@ -590,6 +603,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/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json index 8e4dbbe6..9bebdb61 100644 --- a/io.github.NhekoReborn.Nheko.json +++ b/io.github.NhekoReborn.Nheko.json @@ -146,9 +146,9 @@ "name": "mtxclient", "sources": [ { - "sha256": "6334bb71821a0fde54fe24f02ad393cdb6836633557ffdd239b29c5d5108daaf", - "type": "archive", - "url": "https://github.com/Nheko-Reborn/mtxclient/archive/eddd95a896fad0c51fc800741d82bbc43fc6d41e.tar.gz" + "commit": "744018c86a8094acbda9821d6d7b5a890d4aac47", + "type": "git", + "url": "https://github.com/Nheko-Reborn/mtxclient.git" } ] }, 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..f2bb04f9 100644 --- a/resources/langs/nheko_en.ts +++ b/resources/langs/nheko_en.ts @@ -404,6 +404,21 @@ Example: https://server.my:8787 %1 created and configured room: %2 %1 created and configured room: %2 + + + %1 placed a %2 call. + %1 placed a %2 call. + + + + %1 answered the call. + %1 answered the call. + + + + %1 ended the call. + %1 ended the call. + Placeholder @@ -1796,6 +1811,36 @@ Media size: %2 %1 sent an encrypted message %1 sent an encrypted message + + + You placed a call + You placed a call + + + + %1 placed a call + %1 placed a call + + + + You answered a call + You answered a call + + + + %1 answered a call + %1 answered a call + + + + You ended a call + You ended a call + + + + %1 ended a call + %1 ended a call + popups::UserMentions 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/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml index 17fe7360..ed18b2e5 100644 --- a/resources/qml/delegates/MessageDelegate.qml +++ b/resources/qml/delegates/MessageDelegate.qml @@ -90,6 +90,24 @@ 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 { // TODO: make a more complex formatter for the power levels. roleValue: MtxEvent.PowerLevels diff --git a/resources/res.qrc b/resources/res.qrc index 439ed97b..b245f48f 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 @@ -136,4 +141,9 @@ qml/delegates/Placeholder.qml qml/delegates/Reply.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 d9d1134e..d435dc56 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp @@ -1364,6 +1364,9 @@ Cache::getLastMessageInfo(lmdb::txn &txn, const std::string &room_id) if (!(obj["event"]["type"] == "m.room.message" || obj["event"]["type"] == "m.sticker" || + obj["event"]["type"] == "m.call.invite" || + obj["event"]["type"] == "m.call.answer" || + obj["event"]["type"] == "m.call.hangup" || obj["event"]["type"] == "m.room.encrypted")) continue; 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 36d1fc92..84a5e4d3 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" @@ -40,11 +41,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" @@ -68,6 +71,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) , isConnected_(true) , userSettings_{userSettings} , notificationsManager(this) + , callManager_(userSettings) { setObjectName("chatPage"); @@ -123,11 +127,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_); @@ -446,6 +456,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( @@ -577,6 +616,11 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) connect(this, &ChatPage::dropToLoginPageCb, this, &ChatPage::dropToLoginPage); + connectCallMessage(); + connectCallMessage(); + connectCallMessage(); + connectCallMessage(); + instance_ = this; } @@ -679,6 +723,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 { @@ -1470,3 +1516,13 @@ ChatPage::initiateLogout() emit showOverlayProgressBar(); } + +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 c38d7717..fe63c9d9 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -35,11 +35,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; @@ -50,7 +52,6 @@ class TimelineViewManager; class TopRoomBar; class UserInfoWidget; class UserSettings; -class NotificationsManager; constexpr int CONSENSUS_TIMEOUT = 1000; constexpr int SHOW_CONTENT_TIMEOUT = 3000; @@ -216,6 +217,9 @@ private: void showNotificationsDialog(const QPoint &point); + template + void connectCallMessage(); + QHBoxLayout *topLayout_; Splitter *splitter; @@ -235,6 +239,7 @@ private: TopRoomBar *top_bar_; TextInputWidget *text_input_; + ActiveCallBar *activeCallBar_; QTimer connectivityTimer_; std::atomic_bool isConnected_; @@ -252,6 +257,7 @@ private: QSharedPointer userSettings_; NotificationsManager notificationsManager; + CallManager callManager_; }; template diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp index 7071819b..7846737b 100644 --- a/src/EventAccessors.cpp +++ b/src/EventAccessors.cpp @@ -1,5 +1,7 @@ #include "EventAccessors.h" +#include +#include #include namespace { @@ -65,6 +67,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 @@ -325,6 +350,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 a7577d86..fa70f3eb 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 cc1d868b..4dab3d26 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(); @@ -433,8 +443,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/TextInputWidget.cpp b/src/TextInputWidget.cpp index 3e3915bb..a3392170 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); @@ -652,3 +667,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..27dff57f 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); @@ -161,6 +163,7 @@ signals: 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 +188,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