diff --git a/src/mumble/CMakeLists.txt b/src/mumble/CMakeLists.txt index bd254b62cab..01c7c51ac02 100644 --- a/src/mumble/CMakeLists.txt +++ b/src/mumble/CMakeLists.txt @@ -309,6 +309,8 @@ set(MUMBLE_SOURCES "widgets/SearchDialogTree.h" "widgets/SemanticSlider.cpp" "widgets/SemanticSlider.h" + "widgets/TrayIcon.cpp" + "widgets/TrayIcon.h" "${SHARED_SOURCE_DIR}/ACL.cpp" diff --git a/src/mumble/Global.cpp b/src/mumble/Global.cpp index b52bf12b65d..ecde6e58aeb 100644 --- a/src/mumble/Global.cpp +++ b/src/mumble/Global.cpp @@ -93,6 +93,7 @@ void Global::migrateDataDir(const QDir &toDir) { Global::Global(const QString &qsConfigPath) { mw = nullptr; + trayIcon = nullptr; db = nullptr; pluginManager = nullptr; nam = nullptr; diff --git a/src/mumble/Global.h b/src/mumble/Global.h index 012ee826f7f..54bc3d82ac8 100644 --- a/src/mumble/Global.h +++ b/src/mumble/Global.h @@ -34,6 +34,7 @@ class OverlayClient; class LogEmitter; class DeveloperConsole; class TalkingUI; +class TrayIcon; class QNetworkAccessManager; @@ -50,6 +51,7 @@ struct Global Q_DECL_FINAL { static Global &get(); MainWindow *mw; + TrayIcon *trayIcon; Settings s; boost::shared_ptr< ServerHandler > sh; boost::shared_ptr< AudioInput > ai; diff --git a/src/mumble/Log.cpp b/src/mumble/Log.cpp index 291c1cdf930..8800a9401d1 100644 --- a/src/mumble/Log.cpp +++ b/src/mumble/Log.cpp @@ -818,7 +818,52 @@ void Log::log(MsgType mt, const QString &console, const QString &terse, bool own // Message notification with balloon tooltips if (flags & Settings::LogBalloon) { // Replace any instances of a "Object Replacement Character" from QTextDocumentFragment::toPlainText - // FIXME + plain = plain.replace("\xEF\xBF\xBC", tr("[embedded content]")); + + QSystemTrayIcon::MessageIcon msgIcon = QSystemTrayIcon::NoIcon; + switch (mt) { + case DebugInfo: + case CriticalError: + msgIcon = QSystemTrayIcon::Critical; + break; + case Warning: + msgIcon = QSystemTrayIcon::Warning; + break; + case TextMessage: + case PrivateTextMessage: + msgIcon = QSystemTrayIcon::NoIcon; + break; + case Information: + case ServerConnected: + case ServerDisconnected: + case UserJoin: + case UserLeave: + case Recording: + case YouKicked: + case UserKicked: + case SelfMute: + case OtherSelfMute: + case YouMuted: + case YouMutedOther: + case OtherMutedOther: + case ChannelJoin: + case ChannelLeave: + case PermissionDenied: + case SelfUnmute: + case SelfDeaf: + case SelfUndeaf: + case UserRenamed: + case SelfChannelJoin: + case SelfChannelJoinOther: + case ChannelJoinConnect: + case ChannelLeaveDisconnect: + case ChannelListeningAdd: + case ChannelListeningRemove: + case PluginMessage: + msgIcon = QSystemTrayIcon::Information; + break; + } + emit notificationSpawned(msgName(mt), plain, msgIcon); } } diff --git a/src/mumble/Log.h b/src/mumble/Log.h index 7c7dda3f687..c8c2cffe7a0 100644 --- a/src/mumble/Log.h +++ b/src/mumble/Log.h @@ -6,6 +6,7 @@ #ifndef MUMBLE_MUMBLE_LOG_H_ #define MUMBLE_MUMBLE_LOG_H_ +#include #include #include #include @@ -156,6 +157,10 @@ public slots: const QString &overrideTTS = QString(), bool ignoreTTS = false); /// Logs LogMessages that have been deferred so far void processDeferredLogs(); + +signals: + /// Signal emitted when there was a message received whose type was configured to spawn a notification + void notificationSpawned(QString title, QString body, QSystemTrayIcon::MessageIcon icon); }; class LogMessage { diff --git a/src/mumble/LookConfig.cpp b/src/mumble/LookConfig.cpp index 2267718560f..939e4ed502c 100644 --- a/src/mumble/LookConfig.cpp +++ b/src/mumble/LookConfig.cpp @@ -12,6 +12,7 @@ #include "SearchDialog.h" #include "Global.h" +#include #include #include #include @@ -27,6 +28,15 @@ static ConfigRegistrar registrar(1100, LookConfigNew); LookConfig::LookConfig(Settings &st) : ConfigWidget(st) { setupUi(this); + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + qgbTray->hide(); + } + +#ifdef Q_OS_MAC + // Qt can not hide the window via the native macOS hide function. This should be re-evaluated with new Qt versions. + qcbHideTray->hide(); +#endif + qcbLanguage->addItem(tr("System default")); QDir d(QLatin1String(":"), QLatin1String("mumble_*.qm"), QDir::Name, QDir::Files); foreach (const QString &key, d.entryList()) { diff --git a/src/mumble/MainWindow.cpp b/src/mumble/MainWindow.cpp index f7a6897252f..19eeca9b174 100644 --- a/src/mumble/MainWindow.cpp +++ b/src/mumble/MainWindow.cpp @@ -81,6 +81,7 @@ #include #include "widgets/SemanticSlider.h" +#include "widgets/TrayIcon.h" #ifdef Q_OS_WIN # include @@ -193,6 +194,10 @@ MainWindow::MainWindow(QWidget *p) QObject::connect(this, &MainWindow::serverSynchronized, Global::get().pluginManager, &PluginManager::on_serverSynchronized); + // Set up initial client side talking state without the need for the user to do anything. + // This will, for example, make sure the correct status tray icon is used on connect. + QObject::connect(this, &MainWindow::serverSynchronized, this, &MainWindow::userStateChanged); + QAccessible::installFactory(AccessibleSlider::semanticSliderFactory); } @@ -616,7 +621,6 @@ void MainWindow::closeEvent(QCloseEvent *e) { const bool minimizeDueToConnected = sh && sh->isRunning() && quitBehavior == QuitBehavior::MINIMIZE_WHEN_CONNECTED; if (!forceQuit && (alwaysAsk || askDueToConnected)) { -#ifndef Q_OS_MAC QMessageBox mb(QMessageBox::Warning, QLatin1String("Mumble"), tr("Are you sure you want to close Mumble? Perhaps you prefer to minimize it instead?"), QMessageBox::NoButton, this); @@ -629,7 +633,7 @@ void MainWindow::closeEvent(QCloseEvent *e) { mb.setCheckBox(qcbRemember); mb.exec(); if (mb.clickedButton() == qpbMinimize) { - showMinimized(); + setWindowState(windowState() | Qt::WindowMinimized); e->ignore(); // If checkbox is checked and not connected, always minimize @@ -650,9 +654,8 @@ void MainWindow::closeEvent(QCloseEvent *e) { if (qcbRemember->isChecked()) { Global::get().s.quitBehavior = QuitBehavior::ALWAYS_QUIT; } -#endif } else if (!forceQuit && (alwaysMinimize || minimizeDueToConnected)) { - showMinimized(); + setWindowState(windowState() | Qt::WindowMinimized); e->ignore(); return; } @@ -701,6 +704,20 @@ void MainWindow::showEvent(QShowEvent *e) { } void MainWindow::changeEvent(QEvent *e) { + // Parse minimize event + if (e->type() == QEvent::WindowStateChange) { + // This code block is not triggered on (X)Wayland due to a Qt bug we can do nothing about (QTBUG-74310) + QWindowStateChangeEvent *windowStateEvent = static_cast< QWindowStateChangeEvent * >(e); + if (windowStateEvent) { + bool wasMinimizedState = (windowStateEvent->oldState() & Qt::WindowMinimized); + bool isMinimizedState = (windowState() & Qt::WindowMinimized); + if (!wasMinimizedState && isMinimizedState) { + emit windowMinimized(); + } + return; + } + } + QWidget::changeEvent(e); } @@ -1443,9 +1460,6 @@ void MainWindow::setupView(bool toggle_minimize) { qaTransmitModeSeparator->setVisible(false); } - show(); - activateWindow(); - // If activated show the PTT window if (Global::get().s.bShowPTTButtonWindow && Global::get().s.atTransmit == Settings::PushToTalk) { if (qwPTTButtonWidget) { @@ -2529,6 +2543,8 @@ void MainWindow::updateMenuPermissions() { } void MainWindow::userStateChanged() { + emit talkingStatusChanged(); + ClientUser *user = ClientUser::get(Global::get().uiSession); if (!user) { Global::get().bAttenuateOthers = false; @@ -2599,6 +2615,7 @@ void MainWindow::on_qaAudioMute_triggered() { } updateAudioToolTips(); + emit talkingStatusChanged(); } void MainWindow::setAudioMute(bool mute) { @@ -2643,6 +2660,7 @@ void MainWindow::on_qaAudioDeaf_triggered() { } updateAudioToolTips(); + emit talkingStatusChanged(); } void MainWindow::setAudioDeaf(bool deaf) { @@ -2755,6 +2773,7 @@ void MainWindow::pttReleased() { void MainWindow::on_PushToMute_triggered(bool down, QVariant) { Global::get().bPushToMute = down; updateUserModel(); + emit talkingStatusChanged(); } void MainWindow::on_VolumeUp_triggered(bool down, QVariant) { @@ -3035,7 +3054,9 @@ void MainWindow::on_gsCycleTransmitMode_triggered(bool down, QVariant) { } void MainWindow::on_gsToggleMainWindowVisibility_triggered(bool down, QVariant) { - // FIXME + if (down) { + Global::get().trayIcon->toggleShowHide(); + } } void MainWindow::on_gsListenChannel_triggered(bool down, QVariant scdata) { @@ -3583,6 +3604,8 @@ void MainWindow::serverDisconnected(QAbstractSocket::SocketError err, QString re if (Global::get().s.bMinimalView) { qdwMinimalViewNote->show(); } + + emit disconnectedFromServer(); } void MainWindow::resolverError(QAbstractSocket::SocketError, QString reason) { @@ -3601,13 +3624,13 @@ void MainWindow::resolverError(QAbstractSocket::SocketError, QString reason) { } void MainWindow::showRaiseWindow() { - if (isMinimized()) { - setWindowState((windowState() & ~Qt::WindowMinimized) | Qt::WindowActive); - } - - show(); - raise(); - activateWindow(); + setWindowState(windowState() & ~Qt::WindowMinimized); + QTimer::singleShot(0, [this]() { + show(); + raise(); + activateWindow(); + setWindowState(windowState() | Qt::WindowActive); + }); } void MainWindow::on_qaTalkingUIToggle_triggered() { @@ -3983,8 +4006,10 @@ void MainWindow::openConfigDialog() { if (dlg->exec() == QDialog::Accepted) { setupView(false); + showRaiseWindow(); updateTransmitModeComboBox(Global::get().s.atTransmit); updateUserModel(); + emit talkingStatusChanged(); if (Global::get().s.requireRestartToApply) { if (Global::get().s.requireRestartToApply diff --git a/src/mumble/MainWindow.h b/src/mumble/MainWindow.h index bd436a9d97b..69b7968e8ef 100644 --- a/src/mumble/MainWindow.h +++ b/src/mumble/MainWindow.h @@ -397,6 +397,14 @@ public slots: void userRemovedChannelListener(ClientUser *user, Channel *channel); void transmissionModeChanged(Settings::AudioTransmit newMode); + /// Signal emitted when the local user changes their talking status either actively or passively + void talkingStatusChanged(); + /// Signal emitted when the connection was terminated and all cleanup code has been run + void disconnectedFromServer(); + + /// Signal emitted when the window manager notifies the Mumble MainWindow that the application was just minimized + void windowMinimized(); + public: MainWindow(QWidget *parent); ~MainWindow() Q_DECL_OVERRIDE; diff --git a/src/mumble/main.cpp b/src/mumble/main.cpp index 63edcfe29d2..1e78a745333 100644 --- a/src/mumble/main.cpp +++ b/src/mumble/main.cpp @@ -51,6 +51,8 @@ #include "VersionCheck.h" #include "Global.h" +#include "widgets/TrayIcon.h" + #include #include #include @@ -700,6 +702,8 @@ int main(int argc, char **argv) { Global::get().l = new Log(); Global::get().l->processDeferredLogs(); + Global::get().trayIcon = new TrayIcon(); + #ifdef Q_OS_WIN // Set mumble_mw_hwnd in os_win.cpp. // Used in ASIOInput and GlobalShortcut_win by APIs that require a HWND. diff --git a/src/mumble/widgets/TrayIcon.cpp b/src/mumble/widgets/TrayIcon.cpp new file mode 100644 index 00000000000..c361bcfc32b --- /dev/null +++ b/src/mumble/widgets/TrayIcon.cpp @@ -0,0 +1,177 @@ +// Copyright The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#include "TrayIcon.h" + +#include "ClientUser.h" +#include "Log.h" +#include "MainWindow.h" +#include "Global.h" + +#include + +TrayIcon::TrayIcon() : QSystemTrayIcon(Global::get().mw), m_statusIcon(Global::get().mw->qiIcon) { + setIcon(m_statusIcon); + + setToolTip("Mumble"); + + assert(Global::get().mw); + assert(Global::get().l); + + QObject::connect(Global::get().mw, &MainWindow::talkingStatusChanged, this, &TrayIcon::on_icon_update); + QObject::connect(Global::get().mw, &MainWindow::disconnectedFromServer, this, &TrayIcon::on_icon_update); + QObject::connect(Global::get().mw, &MainWindow::windowMinimized, this, &TrayIcon::on_windowMinimized); + QObject::connect( + Global::get().l, &Log::notificationSpawned, this, + [this](QString title, QString body, QSystemTrayIcon::MessageIcon icon) { showMessage(title, body, icon); }); + + QObject::connect(this, &QSystemTrayIcon::activated, this, &TrayIcon::on_icon_clicked); + + // messageClicked is buggy in Qt on some platforms and we can not do anything about this (QTBUG-87329) + QObject::connect(this, &QSystemTrayIcon::messageClicked, this, &TrayIcon::on_showAction_triggered); + + m_showAction = new QAction(tr("Show"), Global::get().mw); + QObject::connect(m_showAction, &QAction::triggered, this, &TrayIcon::on_showAction_triggered); + + m_hideAction = new QAction(tr("Hide"), Global::get().mw); + QObject::connect(m_hideAction, &QAction::triggered, this, &TrayIcon::on_hideAction_triggered); + + QObject::connect(Global::get().mw->qaTalkingUIToggle, &QAction::triggered, this, &TrayIcon::updateContextMenu); + + m_contextMenu = new QMenu(Global::get().mw); + QObject::connect(m_contextMenu, &QMenu::aboutToShow, this, &TrayIcon::updateContextMenu); + + // Some window managers hate it when a tray icon sets an empty context menu... + updateContextMenu(); + + setContextMenu(m_contextMenu); + + show(); +} + +void TrayIcon::on_icon_update() { + std::reference_wrapper< QIcon > newIcon = Global::get().mw->qiIcon; + + const ClientUser *p = ClientUser::get(Global::get().uiSession); + + if (Global::get().s.bDeaf) { + newIcon = Global::get().mw->qiIconDeafSelf; + } else if (p && p->bDeaf) { + newIcon = Global::get().mw->qiIconDeafServer; + } else if (Global::get().s.bMute) { + newIcon = Global::get().mw->qiIconMuteSelf; + } else if (p && p->bMute) { + newIcon = Global::get().mw->qiIconMuteServer; + } else if (p && p->bSuppress) { + newIcon = Global::get().mw->qiIconMuteSuppressed; + } else if (Global::get().s.bStateInTray && Global::get().bPushToMute) { + newIcon = Global::get().mw->qiIconMutePushToMute; + } else if (p && Global::get().s.bStateInTray) { + switch (p->tsState) { + case Settings::Talking: + case Settings::MutedTalking: + newIcon = Global::get().mw->qiTalkingOn; + break; + case Settings::Whispering: + newIcon = Global::get().mw->qiTalkingWhisper; + break; + case Settings::Shouting: + newIcon = Global::get().mw->qiTalkingShout; + break; + case Settings::Passive: + newIcon = Global::get().mw->qiTalkingOff; + break; + } + } + + if (&newIcon.get() != &m_statusIcon.get()) { + m_statusIcon = newIcon; + setIcon(m_statusIcon); + } +} + +void TrayIcon::on_icon_clicked(QSystemTrayIcon::ActivationReason reason) { + switch (reason) { + case QSystemTrayIcon::Trigger: +#ifndef Q_OS_MAC + // macOS is special as it both shows the context menu AND triggers the action. + // We only want at most one of those and since we can not prevent showing + // the menu, we skip the action. + toggleShowHide(); +#endif + break; + case QSystemTrayIcon::Unknown: + case QSystemTrayIcon::Context: + case QSystemTrayIcon::DoubleClick: + case QSystemTrayIcon::MiddleClick: + break; + } +} + +void TrayIcon::updateContextMenu() { + m_contextMenu->clear(); + + if (Global::get().mw->isVisible() && !Global::get().mw->isMinimized()) { + m_hideAction->setEnabled(QSystemTrayIcon::isSystemTrayAvailable()); + m_contextMenu->addAction(m_hideAction); + } else { + m_contextMenu->addAction(m_showAction); + } + + m_contextMenu->addSeparator(); + + m_contextMenu->addAction(Global::get().mw->qaAudioMute); + m_contextMenu->addAction(Global::get().mw->qaAudioDeaf); + m_contextMenu->addAction(Global::get().mw->qaTalkingUIToggle); + m_contextMenu->addSeparator(); + m_contextMenu->addAction(Global::get().mw->qaQuit); +} + +void TrayIcon::toggleShowHide() { + if (Global::get().mw->isVisible() && !Global::get().mw->isMinimized()) { + on_hideAction_triggered(); + } else { + on_showAction_triggered(); + } +} + +void TrayIcon::on_showAction_triggered() { + Global::get().mw->showRaiseWindow(); + updateContextMenu(); +} + +void TrayIcon::on_hideAction_triggered() { + if (!QSystemTrayIcon::isSystemTrayAvailable()) { + // The system reports that no system tray is available. + // If we would hide Mumble now, there would be no way to + // get it back... + return; + } + + if (qApp->activeModalWidget() || qApp->activePopupWidget()) { + // There is one or multiple modal or popup window(s) active, which + // would not be hidden by this call. So we also do not hide + // the MainWindow... + return; + } + +#ifndef Q_OS_MAC + Global::get().mw->hide(); +#else + // Qt can not hide the window via the native macOS hide function. This should be re-evaluated with new Qt versions. + // Instead we just minimize. + Global::get().mw->setWindowState(Global::get().mw->windowState() | Qt::WindowMinimized); +#endif + + updateContextMenu(); +} + +void TrayIcon::on_windowMinimized() { + if (!Global::get().s.bHideInTray) { + return; + } + + on_hideAction_triggered(); +} diff --git a/src/mumble/widgets/TrayIcon.h b/src/mumble/widgets/TrayIcon.h new file mode 100644 index 00000000000..369927e3fae --- /dev/null +++ b/src/mumble/widgets/TrayIcon.h @@ -0,0 +1,40 @@ +// Copyright The Mumble Developers. All rights reserved. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file at the root of the +// Mumble source tree or at . + +#ifndef MUMBLE_MUMBLE_WIDGETS_TRAYICON_H_ +#define MUMBLE_MUMBLE_WIDGETS_TRAYICON_H_ + +#include + +#include +#include +#include + +class TrayIcon : public QSystemTrayIcon { + Q_OBJECT + +public: + TrayIcon(); + + void toggleShowHide(); + +public slots: + void on_hideAction_triggered(); + void on_showAction_triggered(); + +private: + std::reference_wrapper< QIcon > m_statusIcon; + QMenu *m_contextMenu = nullptr; + QAction *m_showAction = nullptr; + QAction *m_hideAction = nullptr; + + void updateContextMenu(); + +private slots: + void on_icon_clicked(QSystemTrayIcon::ActivationReason reason); + void on_windowMinimized(); +}; + +#endif // MUMBLE_MUMBLE_WIDGETS_TRAYICON_H_