feat(BRIDGE-37): Remote notification support

This commit is contained in:
Atanas Janeshliev
2024-08-29 13:31:37 +02:00
parent ed1b65731a
commit f04350c046
43 changed files with 2350 additions and 1168 deletions

View File

@ -1330,6 +1330,7 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::knowledgeBasSuggestionsReceived, this, &QMLBackend::receivedKnowledgeBaseSuggestions);
connect(client, &GRPCClient::repairStarted, this, &QMLBackend::repairStarted);
connect(client, &GRPCClient::allUsersLoaded, this, &QMLBackend::allUsersLoaded);
connect(client, &GRPCClient::userNotificationReceived, this, &QMLBackend::processUserNotification);
// cache events
connect(client, &GRPCClient::cantMoveDiskCache, this, &QMLBackend::cantMoveDiskCache);
@ -1418,3 +1419,25 @@ void QMLBackend::triggerRepair() const {
app().grpc().triggerRepair();
)
}
//****************************************************************************************************************************************************
/// \param[in] notification The user notification received from the event loop.
//****************************************************************************************************************************************************
void QMLBackend::processUserNotification(bridgepp::UserNotification const& notification) {
this->userNotificationStack_.push(notification);
trayIcon_->showUserNotification(notification.title, notification.subtitle);
emit receivedUserNotification(notification);
}
void QMLBackend::userNotificationDismissed() {
if (!this->userNotificationStack_.size()) return;
// Remove the user notification from the top of the queue as it has been dismissed.
this->userNotificationStack_.pop();
if (!this->userNotificationStack_.size()) return;
// Display the user notification that is on top of the queue, if there is one.
auto notification = this->userNotificationStack_.top();
emit receivedUserNotification(notification);
}

View File

@ -28,6 +28,7 @@
#include <bridgepp/GRPC/GRPCClient.h>
#include <bridgepp/GRPC/GRPCUtils.h>
#include <bridgepp/Worker/Overseer.h>
#include <stack>
//****************************************************************************************************************************************************
@ -174,6 +175,8 @@ signals: // Signal used by the Qt property system. Many of them are unused but r
void isAutostartOnChanged(bool value); ///<Signal for the change of the 'isAutostartOn' property.
void usersChanged(UserList *users); ///<Signal for the change of the 'users' property.
void dockIconVisibleChanged(bool value); ///<Signal for the change of the 'dockIconVisible' property.
void receivedUserNotification(bridgepp::UserNotification const& notification); ///< Signal to display the userNotification modal
public slots: // slot for signals received from QML -> To be forwarded to Bridge via RPC Client calls.
void toggleAutostart(bool active); ///< Slot for the autostart toggle.
@ -209,6 +212,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void notifyAutoconfigClicked(QString const &client) const; ///< Slot for gAutoconfigClicked gRPC event.
void notifyExternalLinkClicked(QString const &article) const; ///< Slot for KBArticleClicked gRPC event.
void triggerRepair() const; ///< Slot for the triggering of the bridge repair function i.e. 'resync'.
void userNotificationDismissed(); ///< Slot to pop the notification from the stack and display the rest.
public slots: // slots for functions that need to be processed locally.
void setNormalTrayIcon(); ///< Set the tray icon to normal.
@ -224,6 +228,7 @@ public slots: // slot for signals received from gRPC that need transformation in
void onLoginAlreadyLoggedIn(QString const &userID); ///< Slot for the LoginAlreadyLoggedIn gRPC event.
void onUserBadEvent(QString const& userID, QString const& errorMessage); ///< Slot for the userBadEvent gRPC event.
void onIMAPLoginFailed(QString const& username); ///< Slot the the imapLoginFailed event.
void processUserNotification(bridgepp::UserNotification const& notification); ///< Slot for the userNotificationReceived gRCP event.
signals: // Signals received from the Go backend, to be forwarded to QML
void toggleAutostartFinished(); ///< Signal for the 'toggleAutostartFinished' gRPC stream event.
@ -310,6 +315,7 @@ private: // data members
QList<QString> badEventDisplayQueue_; ///< THe queue for displaying 'bad event feedback request dialog'.
std::unique_ptr<TrayIcon> trayIcon_; ///< The tray icon for the application.
bridgepp::BugReportFlow reportFlow_; ///< The bug report flow.
std::stack<bridgepp::UserNotification> userNotificationStack_; ///< The stack which holds all of the active notifications that the user needs to acknowledge.
friend class AppController;
};

View File

@ -71,6 +71,7 @@
<file>qml/icons/systray-mono-update.png</file>
<file>qml/icons/systray-mono-warn.png</file>
<file>qml/icons/systray.svg</file>
<file>qml/icons/ic-notification-bell.svg</file>
<file alias="bridge.svg">../../../../dist/bridge.svg</file>
<file alias="bridgeMacOS.svg">../../../../dist/bridgeMacOS.svg</file>
<file>qml/KeychainSettings.qml</file>
@ -78,6 +79,7 @@
<file>qml/MainWindow.qml</file>
<file>qml/NoAccountView.qml</file>
<file>qml/NotificationDialog.qml</file>
<file>qml/UserNotificationDialog.qml</file>
<file>qml/NotificationPopups.qml</file>
<file>qml/Notifications/Notification.qml</file>
<file>qml/Notifications/NotificationFilter.qml</file>

View File

@ -331,6 +331,15 @@ void TrayIcon::showErrorPopupNotification(QString const &title, QString const &m
this->showMessage(title, message, notificationErrorIcon_);
}
//****************************************************************************************************************************************************
/// Used only by user notifications received from the event loop
/// \param[in] title The title.
/// \param[in] subtitle The subtitle.
//****************************************************************************************************************************************************
void TrayIcon::showUserNotification(QString const &title, QString const &subtitle) {
this->showMessage(title, subtitle, QSystemTrayIcon::NoIcon);
}
//****************************************************************************************************************************************************
/// \param[in] svgPath The path of the SVG file for the icon.

View File

@ -42,6 +42,8 @@ public: // data members
TrayIcon& operator=(TrayIcon&&) = delete; ///< Disabled move assignment operator.
void setState(State state, QString const& stateString, QString const &statusIconPath); ///< Set the state of the icon
void showErrorPopupNotification(QString const& title, QString const &message); ///< Display a pop up notification.
void showUserNotification(QString const& title, QString const &subtitle); ///< Display an OS pop up notification (without icon).
signals:
void selectUser(QString const& userID, bool forceShowWindow); ///< Signal for selecting a user with a given userID

View File

@ -22,6 +22,7 @@ Dialog {
default property alias data: additionalChildrenContainer.children
property var notification
property bool isUserNotification: false
modal: true
shouldShow: notification && notification.active && !notification.dismissed
@ -39,13 +40,13 @@ Dialog {
return "";
}
switch (root.notification.type) {
case Notification.NotificationType.Info:
return "/qml/icons/ic-info.svg";
case Notification.NotificationType.Success:
return "/qml/icons/ic-success.svg";
case Notification.NotificationType.Warning:
case Notification.NotificationType.Danger:
return "/qml/icons/ic-alert.svg";
case Notification.NotificationType.Info:
return "/qml/icons/ic-info.svg";
case Notification.NotificationType.Success:
return "/qml/icons/ic-success.svg";
case Notification.NotificationType.Warning:
case Notification.NotificationType.Danger:
return "/qml/icons/ic-alert.svg";
}
}
sourceSize.height: 64

View File

@ -109,4 +109,8 @@ Item {
colorScheme: root.colorScheme
notification: root.notifications.repairBridge
}
UserNotificationDialog {
colorScheme: root.colorScheme
notification: root.notifications.userNotification
}
}

View File

@ -19,7 +19,8 @@ QtObject {
Info,
Success,
Warning,
Danger
Danger,
UserNotification
}
property list<Action> action
@ -36,6 +37,9 @@ QtObject {
readonly property var occurred: active ? new Date() : undefined
property string title // title is used in dialogs only
property int type
property string subtitle
property string username
onActiveChanged: {
dismissed = false;

View File

@ -62,7 +62,7 @@ QtObject {
target: Backend
}
}
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent, root.repairBridge]
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent, root.repairBridge, root.userNotification]
property Notification alreadyLoggedIn: Notification {
brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.")
@ -1187,7 +1187,7 @@ QtObject {
}
target: root
}
Connections {
function onRepairStarted() {
root.repairBridge.active = false;
@ -1200,6 +1200,35 @@ QtObject {
}
property Notification userNotification: Notification {
brief: title
group: Notifications.Group.Dialogs
type: Notification.NotificationType.UserNotification
icon: "./icons/ic-exclamation-circle-filled.svg" // If it's not included QML complains
action: [
Action {
text: qsTr("Okay")
onTriggered: {
root.userNotification.active = false;
Backend.userNotificationDismissed();
}
}
]
Connections {
function onReceivedUserNotification(notification) {
const userPrimaryEmailOrUsername = Backend.users.primaryEmailOrUsername(notification.userID)
root.userNotification.title = notification.title
root.userNotification.subtitle = notification.subtitle
root.userNotification.description = notification.body
root.userNotification.username = userPrimaryEmailOrUsername
root.userNotification.active = true
}
target: Backend
}
}
signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user)
signal askEnableBeta

View File

@ -73,6 +73,11 @@ T.ApplicationWindow {
if (obj.shouldShow === false) {
continue;
}
// User notifications should have display priority
if (obj.shouldShow && obj.isUserNotification) {
topmost = obj;
break;
}
if (topmost && (topmost.popupType > obj.popupType)) {
continue;
}

View File

@ -0,0 +1,120 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
import QtQml
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Proton
import Notifications
Dialog {
id: root
property var notification
property bool isUserNotification: true
padding: 40
modal: true
shouldShow: notification && notification.active && !notification.dismissed
ColumnLayout {
spacing: 0
Image {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 16
Layout.preferredHeight: 64
Layout.preferredWidth: 64
source: {
if (!root.notification) {
return "";
}
switch (root.notification.type) {
case Notification.NotificationType.UserNotification:
return "/qml/icons/ic-notification-bell.svg"
}
}
sourceSize.height: 64
sourceSize.width: 64
visible: source != ""
}
// Title Label
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 4
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.title
wrapMode: Text.WordWrap
type: Label.LabelType.Title
}
// Username or primary email
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 24
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.username
wrapMode: Text.WordWrap
visible: root.notification.username.length > 0
type: Label.LabelType.Caption
}
// Subtitle
Label {
Layout.alignment: Qt.AlignHCenter
Layout.bottomMargin: 24
Layout.fillWidth: true
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.subtitle
wrapMode: Text.WordWrap
visible: root.notification.subtitle.length > 0
type: Label.LabelType.Lead
color: root.colorScheme.text_weak
}
Label {
Layout.bottomMargin: 24
Layout.fillWidth: true
Layout.preferredWidth: 320
colorScheme: root.colorScheme
horizontalAlignment: Text.AlignHCenter
text: root.notification.description
type: Label.LabelType.Body
wrapMode: Text.WordWrap
onLinkActivated: function (link) {
Backend.openExternalLink(link);
}
}
ColumnLayout {
spacing: 40
Repeater {
model: root.notification.action
delegate: Button {
Layout.fillWidth: true
action: modelData
colorScheme: root.colorScheme
loading: modelData.loading
secondary: index > 0
}
}
}
}
}

View File

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64">
<g clip-path="url(#a)">
<circle cx="13.031" cy="5.288" r="3.166" fill="#2C83DC" transform="rotate(-30 13.031 5.288)"/>
<path fill="url(#b)" d="M3.599 27.28A19.757 19.757 0 0 1 36.793 8.115L53.92 25.808a12.42 12.42 0 0 0 4.581 2.998c3.454 1.288 5.556 4.214 3.201 7.05-2.76 3.325-8.795 8.475-21.796 15.981S19.428 61.994 15.169 62.723c-3.634.621-5.117-2.662-4.506-6.298a12.422 12.422 0 0 0-.306-5.466L3.6 27.28Z"/>
<path fill="url(#c)" d="M3.599 27.28A19.757 19.757 0 0 1 36.793 8.115L53.92 25.808a12.42 12.42 0 0 0 4.581 2.998c3.454 1.288 5.556 4.214 3.201 7.05-2.76 3.325-8.795 8.475-21.796 15.981S19.428 61.994 15.169 62.723c-3.634.621-5.117-2.662-4.506-6.298a12.422 12.422 0 0 0-.306-5.466L3.6 27.28Z"/>
<ellipse cx="37.094" cy="46.965" fill="url(#d)" rx="26.875" ry="3.75" transform="rotate(-30 37.094 46.965)"/>
<ellipse cx="37.094" cy="46.965" fill="url(#e)" rx="26.875" ry="3.75" transform="rotate(-30 37.094 46.965)"/>
<path fill="#fff" fill-opacity=".2" d="m48.156 19.855-2.032-2.1c-6.302 1.79-12.908 4.57-19.41 8.324-7.591 4.383-14.103 9.553-19.19 14.959l.914 3.201c4.88-5.52 11.835-11.152 20.16-15.958 6.703-3.87 13.425-6.704 19.558-8.426Z"/>
<circle cx="36.469" cy="45.883" r="2.5" fill="url(#f)" transform="rotate(-30 36.469 45.883)"/>
</g>
<defs>
<linearGradient id="d" x1="49.16" x2="37.094" y1="30.878" y2="49.439" gradientUnits="userSpaceOnUse">
<stop stop-color="#256097"/>
<stop offset="1" stop-color="#2C83DC"/>
</linearGradient>
<linearGradient id="e" x1="49.68" x2="49.037" y1="46.478" y2="51.592" gradientUnits="userSpaceOnUse">
<stop stop-color="#27ABF4" stop-opacity="0"/>
<stop offset="1" stop-color="#27ABF4" stop-opacity=".5"/>
</linearGradient>
<linearGradient id="f" x1="35.719" x2="36.469" y1="43.133" y2="48.383" gradientUnits="userSpaceOnUse">
<stop offset=".345" stop-color="#B2EAFE" stop-opacity="0"/>
<stop offset="1" stop-color="#B2EAFE"/>
</linearGradient>
<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="rotate(131.347 12.11 6.294) scale(52.6374 54.7116)" gradientUnits="userSpaceOnUse">
<stop stop-color="#B2EAFE"/>
<stop offset="1" stop-color="#27ABF4"/>
</radialGradient>
<radialGradient id="c" cx="0" cy="0" r="1" gradientTransform="rotate(60 -23.22 57.501) scale(21.25 76.0937)" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0"/>
<stop offset=".46" stop-color="#fff" stop-opacity=".4"/>
<stop offset=".58" stop-color="#B2EAFE" stop-opacity=".5"/>
</radialGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h64v64H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB