From 6ed407314da2bbb383e89203a0ee2b18c2174039 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 11 Jun 2026 18:22:23 +0200 Subject: [PATCH 01/20] Add user status and more items to the tray's user menu Signed-off-by: Rello --- resources.qrc | 3 + src/gui/CMakeLists.txt | 2 + src/gui/EmojiPicker.qml | 75 +- src/gui/UserStatusWindow.qml | 355 +++++++++ .../UserStatusWindowPredefinedStatusRow.qml | 78 ++ src/gui/UserStatusWindowStatusRow.qml | 79 ++ src/gui/macOS/trayaccountpopup_mac.mm | 701 +++++++++++++++++- src/gui/owncloudgui.cpp | 2 + src/gui/systray.cpp | 79 ++ src/gui/systray.h | 2 + src/gui/tray/TrayAccountPopup.qml | 319 +++++++- src/gui/tray/trayaccountappsmodel.cpp | 117 +++ src/gui/tray/trayaccountappsmodel.h | 53 ++ src/gui/userstatusselectormodel.cpp | 33 + src/gui/userstatusselectormodel.h | 12 + test/testsetuserstatusdialog.cpp | 90 +++ 16 files changed, 1972 insertions(+), 28 deletions(-) create mode 100644 src/gui/UserStatusWindow.qml create mode 100644 src/gui/UserStatusWindowPredefinedStatusRow.qml create mode 100644 src/gui/UserStatusWindowStatusRow.qml create mode 100644 src/gui/tray/trayaccountappsmodel.cpp create mode 100644 src/gui/tray/trayaccountappsmodel.h diff --git a/resources.qrc b/resources.qrc index ba9cdae0e0706..b56d2b29df61f 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,6 +1,9 @@ src/gui/UserStatusMessageView.qml + src/gui/UserStatusWindow.qml + src/gui/UserStatusWindowStatusRow.qml + src/gui/UserStatusWindowPredefinedStatusRow.qml src/gui/UserStatusSelectorPage.qml src/gui/EmojiPicker.qml src/gui/UserStatusSelectorButton.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 0a16af221914a..7a0ecf41d7205 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -207,6 +207,8 @@ set(client_SRCS tray/unifiedsearchresult.cpp tray/unifiedsearchresultslistmodel.h tray/trayimageprovider.cpp + tray/trayaccountappsmodel.h + tray/trayaccountappsmodel.cpp tray/unifiedsearchresultslistmodel.cpp tray/usermodel.h tray/usermodel.cpp diff --git a/src/gui/EmojiPicker.qml b/src/gui/EmojiPicker.qml index f49410d6ea399..8e6dcb2b61740 100644 --- a/src/gui/EmojiPicker.qml +++ b/src/gui/EmojiPicker.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Controls +import QtQuick.Controls.Basic as BasicControls import QtQuick.Layouts import Style @@ -12,6 +13,42 @@ import com.nextcloud.desktopclient 1.0 as NC import "./tray" ColumnLayout { + id: root + + property bool showSearch: false + property int visibleRows: 8 + property string searchText: "" + readonly property var filteredModel: { + if (searchText === "") { + return emojiModel.model + } + + const needle = searchText.toLowerCase() + const emojiLists = [ + emojiModel.people, + emojiModel.nature, + emojiModel.food, + emojiModel.activity, + emojiModel.travel, + emojiModel.objects, + emojiModel.symbols, + emojiModel.flags + ] + var results = [] + for (var listIndex = 0; listIndex < emojiLists.length; ++listIndex) { + const emojis = emojiLists[listIndex] + for (var emojiIndex = 0; emojiIndex < emojis.length; ++emojiIndex) { + const emoji = emojis[emojiIndex] + const shortname = emoji.shortname === undefined ? "" : emoji.shortname.toLowerCase() + const unicode = emoji.unicode === undefined ? "" : emoji.unicode + if (shortname.indexOf(needle) !== -1 || unicode.indexOf(searchText) !== -1) { + results.push(emoji) + } + } + } + return results + } + NC.EmojiModel { id: emojiModel } @@ -24,6 +61,32 @@ ColumnLayout { id: metrics } + BasicControls.TextField { + id: searchField + + visible: root.showSearch + Layout.fillWidth: true + Layout.preferredHeight: visible ? 32 : 0 + Layout.margins: visible ? Style.smallSpacing : 0 + placeholderText: qsTr("Search emoji") + selectByMouse: true + text: root.searchText + topPadding: 0 + bottomPadding: 0 + leftPadding: 10 + rightPadding: 10 + font.pixelSize: Style.pixelSize + 2 + verticalAlignment: TextInput.AlignVCenter + onTextChanged: root.searchText = text + + background: Rectangle { + radius: 8 + color: palette.base + border.width: 1 + border.color: searchField.activeFocus ? Style.ncBlue : palette.dark + } + } + ListView { id: headerLayout Layout.fillWidth: true @@ -31,6 +94,8 @@ ColumnLayout { implicitWidth: contentItem.childrenRect.width implicitHeight: metrics.height * 2 + visible: root.searchText === "" + Layout.preferredHeight: visible ? implicitHeight : 0 orientation: ListView.Horizontal model: emojiModel.emojiCategoriesModel @@ -74,7 +139,7 @@ ColumnLayout { } Rectangle { - height: Style.normalBorderWidth + Layout.preferredHeight: root.searchText === "" ? Style.normalBorderWidth : 0 Layout.fillWidth: true color: palette.dark } @@ -82,7 +147,7 @@ ColumnLayout { ScrollView { Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredHeight: metrics.height * 8 + Layout.preferredHeight: metrics.height * root.visibleRows Layout.margins: Style.normalBorderWidth clip: true @@ -95,7 +160,7 @@ ColumnLayout { cellWidth: metrics.height * 2 cellHeight: metrics.height * 2 boundsBehavior: Flickable.DragOverBounds - model: emojiModel.model + model: root.filteredModel delegate: ItemDelegate { id: emojiDelegate @@ -129,11 +194,11 @@ ColumnLayout { } - EnforcedPlainTextLabel { + EnforcedPlainTextLabel { id: placeholderMessage width: parent.width * 0.8 anchors.centerIn: parent - text: qsTr("No recent emojis") + text: root.searchText === "" ? qsTr("No recent emojis") : qsTr("No emojis found") wrapMode: Text.Wrap font.bold: true visible: emojiView.count === 0 diff --git a/src/gui/UserStatusWindow.qml b/src/gui/UserStatusWindow.qml new file mode 100644 index 0000000000000..6c0819e647a88 --- /dev/null +++ b/src/gui/UserStatusWindow.qml @@ -0,0 +1,355 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Controls.Basic as BasicControls +import QtQuick.Layouts + +import com.nextcloud.desktopclient as NC +import Style +import "./tray" +import "./wizard/qml" + +ApplicationWindow { + id: root + + property int userIndex: -1 + + readonly property int sectionSpacing: 12 + readonly property int rowSpacing: 8 + readonly property int contentWidth: 560 + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + title: qsTr("Online status") + width: contentWidth + height: 700 + minimumWidth: 520 + minimumHeight: 560 + flags: Qt.Window + | Qt.CustomizeWindowHint + | Qt.WindowTitleHint + | Qt.WindowSystemMenuHint + | Qt.WindowCloseButtonHint + color: Style.wizardWindowBackground + palette.window: Style.wizardWindowBackground + palette.windowText: Style.wizardPrimaryText + palette.base: Style.wizardFieldBackground + palette.text: Style.wizardPrimaryText + palette.button: Style.wizardFieldBackground + palette.buttonText: Style.wizardPrimaryText + palette.mid: Style.wizardDisabledText + palette.placeholderText: Style.wizardPlaceholderText + + background: Rectangle { + color: Style.wizardWindowBackground + } + + NC.UserStatusSelectorModel { + id: statusModel + + finishOnOnlineStatusSet: false + onFinished: root.close() + } + + Binding { + target: statusModel + property: "userIndex" + value: root.userIndex + when: root.userIndex >= 0 + } + + function setOnlineStatus(status) { + if (statusModel.onlineStatus !== status) { + statusModel.onlineStatus = status + } + } + + function saveStatusMessage() { + if (statusModel.userStatusMessage !== statusMessageField.text) { + statusModel.userStatusMessage = statusMessageField.text + } + statusModel.setUserStatus() + } + + Shortcut { + sequences: [StandardKey.Cancel] + onActivated: root.close() + } + + Connections { + target: statusModel + + function onUserStatusChanged() { + if (!statusMessageField.activeFocus) { + statusMessageField.text = statusModel.userStatusMessage + } + } + } + + WizardDialogFrame { + id: frame + + anchors.fill: parent + + ColumnLayout { + anchors.fill: parent + anchors.margins: frame.windowMargin + spacing: root.sectionSpacing + + ColumnLayout { + Layout.fillWidth: true + spacing: root.rowSpacing + + UserStatusWindowStatusRow { + Layout.fillWidth: true + selected: statusModel.onlineStatus === NC.userStatus.Online + iconSource: statusModel.onlineIcon + text: qsTr("Online") + onClicked: root.setOnlineStatus(NC.userStatus.Online) + } + + UserStatusWindowStatusRow { + Layout.fillWidth: true + selected: statusModel.onlineStatus === NC.userStatus.Away + iconSource: statusModel.awayIcon + text: qsTr("Away") + onClicked: root.setOnlineStatus(NC.userStatus.Away) + } + + UserStatusWindowStatusRow { + Layout.fillWidth: true + visible: statusModel.busyStatusSupported + selected: statusModel.onlineStatus === NC.userStatus.Busy + iconSource: statusModel.busyIcon + text: qsTr("Busy") + onClicked: root.setOnlineStatus(NC.userStatus.Busy) + } + + UserStatusWindowStatusRow { + Layout.fillWidth: true + selected: statusModel.onlineStatus === NC.userStatus.DoNotDisturb + iconSource: statusModel.dndIcon + text: qsTr("Do not disturb") + secondaryText: qsTr("Mute all notifications") + onClicked: root.setOnlineStatus(NC.userStatus.DoNotDisturb) + } + + UserStatusWindowStatusRow { + Layout.fillWidth: true + selected: statusModel.onlineStatus === NC.userStatus.Invisible + || statusModel.onlineStatus === NC.userStatus.Offline + iconSource: statusModel.invisibleIcon + text: qsTr("Invisible") + secondaryText: qsTr("Appear offline") + onClicked: root.setOnlineStatus(NC.userStatus.Invisible) + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: root.rowSpacing + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: qsTr("Status message") + color: Style.wizardPrimaryText + font.pixelSize: Style.pixelSize + 6 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + BasicControls.Button { + id: emojiButton + + readonly property string fallbackEmoji: "😀" + + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + padding: 0 + text: statusModel.userStatusEmoji.length > 0 + ? statusModel.userStatusEmoji + : fallbackEmoji + Accessible.role: Accessible.Button + Accessible.name: qsTr("Choose emoji") + onClicked: emojiPopup.open() + + contentItem: Text { + text: emojiButton.text + opacity: statusModel.userStatusEmoji.length > 0 ? 1.0 : 0.7 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: Style.pixelSize + 6 + } + + background: Rectangle { + color: emojiButton.hovered ? Style.wizardRowBackground : "transparent" + } + } + + WizardTextField { + id: statusMessageField + + Layout.fillWidth: true + Layout.preferredHeight: 36 + placeholderText: qsTr("What is your status?") + selectByMouse: true + Component.onCompleted: text = statusModel.userStatusMessage + onEditingFinished: statusModel.userStatusMessage = text + } + } + + BasicControls.Popup { + id: emojiPopup + + width: 420 + height: 360 + padding: 0 + margins: 0 + modal: false + clip: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + + background: Rectangle { + color: Style.wizardWindowBackground + border.width: 1 + border.color: Style.wizardFieldBorder + radius: 8 + } + + EmojiPicker { + width: emojiPopup.availableWidth + height: emojiPopup.availableHeight + showSearch: true + visibleRows: 10 + + onChosen: { + statusModel.userStatusEmoji = emoji + emojiPopup.close() + } + } + } + + ScrollView { + id: predefinedStatusesScrollView + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Column { + width: predefinedStatusesScrollView.availableWidth + spacing: 2 + + Repeater { + model: statusModel.predefinedStatuses + + delegate: UserStatusWindowPredefinedStatusRow { + width: parent.width + emoji: modelData.icon + statusText: modelData.message + clearAtText: statusModel.clearAtReadable(modelData) + selected: statusModel.userStatusMessage === modelData.message + && statusModel.userStatusEmoji === modelData.icon + onClicked: { + statusModel.setPredefinedStatus(modelData) + statusMessageField.text = statusModel.userStatusMessage + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + EnforcedPlainTextLabel { + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: 36 + text: qsTr("Clear status after") + color: Style.wizardPrimaryText + font.pixelSize: Style.pixelSize + 3 + verticalAlignment: Text.AlignVCenter + wrapMode: Text.Wrap + } + + BasicControls.ComboBox { + id: clearAtComboBox + + Layout.fillWidth: true + Layout.preferredHeight: 36 + leftPadding: 12 + rightPadding: 32 + topPadding: 0 + bottomPadding: 0 + font.pixelSize: Style.pixelSize + 3 + model: statusModel.clearStageTypes + textRole: "display" + valueRole: "clearStageType" + displayText: statusModel.clearAtDisplayString + Accessible.name: qsTr("Clear status after") + onActivated: statusModel.setClearAt(currentValue) + + contentItem: Text { + text: clearAtComboBox.displayText + font: clearAtComboBox.font + color: Style.wizardPrimaryText + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 8 + color: Style.wizardFieldBackground + border.width: 1 + border.color: clearAtComboBox.activeFocus ? Style.ncBlue : Style.wizardFieldBorder + } + } + } + + ErrorBox { + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: statusModel.errorMessage !== "" + text: statusModel.errorMessage + } + } + } + + footer: [ + WizardButton { + text: qsTr("Clear status message") + onClicked: statusModel.clearUserStatus() + }, + + Item { + Layout.fillWidth: true + }, + + WizardButton { + primary: true + text: qsTr("Set status message") + onClicked: root.saveStatusMessage() + } + ] + } +} diff --git a/src/gui/UserStatusWindowPredefinedStatusRow.qml b/src/gui/UserStatusWindowPredefinedStatusRow.qml new file mode 100644 index 0000000000000..2d1d131e655d8 --- /dev/null +++ b/src/gui/UserStatusWindowPredefinedStatusRow.qml @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls +import QtQuick.Layouts + +import Style +import "./tray" + +BasicControls.Button { + id: root + + property string emoji: "" + property string statusText: "" + property string clearAtText: "" + property bool selected: false + + hoverEnabled: true + leftPadding: 0 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 30 + + Accessible.role: Accessible.Button + Accessible.name: qsTr("%1, clears %2").arg(statusText).arg(clearAtText) + + background: Rectangle { + radius: 8 + color: root.hovered ? Style.wizardRowBackground : "transparent" + border.width: root.selected || root.activeFocus ? 2 : 0 + border.color: root.activeFocus ? Style.ncBlue + : root.selected ? Style.ncBlue + : "transparent" + } + + contentItem: Item { + implicitWidth: Math.max(0, root.width - root.leftPadding - root.rightPadding) + implicitHeight: contentLayout.implicitHeight + + RowLayout { + id: contentLayout + + anchors.fill: parent + spacing: 8 + + EnforcedPlainTextLabel { + Layout.preferredWidth: 36 + text: root.emoji + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: Style.pixelSize + 3 + } + + EnforcedPlainTextLabel { + Layout.maximumWidth: implicitWidth + text: root.statusText + color: Style.wizardPrimaryText + font.pixelSize: Style.pixelSize + 1 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: qsTr("- %1").arg(root.clearAtText) + color: Style.wizardSecondaryText + font.pixelSize: Style.pixelSize + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + } + } +} diff --git a/src/gui/UserStatusWindowStatusRow.qml b/src/gui/UserStatusWindowStatusRow.qml new file mode 100644 index 0000000000000..6d05845d4b889 --- /dev/null +++ b/src/gui/UserStatusWindowStatusRow.qml @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls +import QtQuick.Layouts + +import Style +import "./tray" + +BasicControls.Button { + id: root + + property string secondaryText: "" + property url iconSource: "" + property bool selected: false + + hoverEnabled: true + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 44 + + Accessible.role: Accessible.Button + Accessible.name: secondaryText === "" ? text : qsTr("%1, %2").arg(text).arg(secondaryText) + + background: Rectangle { + radius: 8 + color: root.hovered ? Style.wizardSelectedBackground : Style.wizardRowBackground + border.width: root.selected || root.activeFocus ? 2 : 0 + border.color: root.selected ? Style.ncBlue + : root.activeFocus ? Style.ncBlue + : "transparent" + } + + contentItem: Item { + implicitWidth: Math.max(0, root.width - root.leftPadding - root.rightPadding) + implicitHeight: contentLayout.implicitHeight + + RowLayout { + id: contentLayout + + anchors.fill: parent + spacing: 12 + + Image { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + source: root.iconSource + sourceSize: Qt.size(18, 18) + fillMode: Image.PreserveAspectFit + visible: root.iconSource.toString() !== "" + } + + EnforcedPlainTextLabel { + Layout.fillWidth: root.secondaryText === "" + Layout.preferredWidth: root.secondaryText === "" ? implicitWidth : 160 + text: root.text + color: root.enabled ? Style.wizardPrimaryText : Style.wizardDisabledText + font.pixelSize: Style.pixelSize + 3 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + visible: root.secondaryText !== "" + text: root.secondaryText + color: root.enabled ? Style.wizardSecondaryText : Style.wizardDisabledText + font.pixelSize: Style.pixelSize + 1 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + } + } +} diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 67e1a07a719fd..b7f865c244dc5 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -4,8 +4,10 @@ */ #include "systray.h" +#include "tray/trayaccountappsmodel.h" #include "tray/usermodel.h" +#include #include #import @@ -26,6 +28,10 @@ static const CGFloat kHoverMargin = 5.0; static const CGFloat kHoverRadius = 5.0; static const CGFloat kAccountHoverVerticalMargin = 4.0; +static const CGFloat kAccountActionsPopupWidth = 190.0; +static const CGFloat kAppsPopupWidth = 220.0; + +typedef void (^NCActionHoverBlock)(NSView *row); static NSColor *hoverColor() { @@ -58,6 +64,71 @@ return img; } +static QImage qImageFromQUrl(const QUrl &url) +{ + if (url.isEmpty()) return {}; + + auto imagePath = QString{}; + if (url.isLocalFile()) { + imagePath = url.toLocalFile(); + } else if (url.scheme() == QStringLiteral("qrc")) { + imagePath = QStringLiteral(":%1").arg(url.path()); + } else { + imagePath = url.toString(); + } + return QImage(imagePath); +} + +static NSImage *nsImageFromQUrl(const QUrl &url) +{ + return nsImageFromQImage(qImageFromQUrl(url)); +} + +static QString statusText(OCC::UserStatus::OnlineStatus status) +{ + switch (status) { + case OCC::UserStatus::OnlineStatus::Online: + return QCoreApplication::translate("UserStatusSetStatusView", "Online"); + case OCC::UserStatus::OnlineStatus::Away: + return QCoreApplication::translate("UserStatusSetStatusView", "Away"); + case OCC::UserStatus::OnlineStatus::Busy: + return QCoreApplication::translate("UserStatusSetStatusView", "Busy"); + case OCC::UserStatus::OnlineStatus::DoNotDisturb: + return QCoreApplication::translate("UserStatusSetStatusView", "Do not disturb"); + case OCC::UserStatus::OnlineStatus::Invisible: + return QCoreApplication::translate("UserStatusSetStatusView", "Invisible"); + case OCC::UserStatus::OnlineStatus::Offline: + return QCoreApplication::translate("OCC::SyncStatusSummary", "Offline"); + } + return QCoreApplication::translate("UserStatusSetStatusView", "Online"); +} + +static QString trayFoldersMenuButtonText(const char *sourceText) +{ + return QCoreApplication::translate("TrayFoldersMenuButton", sourceText); +} + +static QString mainWindowText(const char *sourceText) +{ + return QCoreApplication::translate("MainWindow", sourceText); +} + +static QString fileDetailsPageText(const char *sourceText) +{ + return QCoreApplication::translate("FileDetailsPage", sourceText); +} + +static QString trayWindowHeaderText(const char *sourceText) +{ + return QCoreApplication::translate("TrayWindowHeader", sourceText); +} + +static QString statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) +{ + const auto trimmedMessage = message.trimmed(); + return trimmedMessage.isEmpty() ? statusText(status) : trimmedMessage; +} + @interface NCHoverView : NSView @end @@ -96,17 +167,23 @@ - (void)updateTrackingAreas @end +@class NCAccountRow; + @protocol NCAccountRowDelegate - (void)onAccountRowClicked:(int)index; +- (void)onAccountRowHovered:(NCAccountRow *)row; @end @interface NCAccountRow : NCHoverView @property (nonatomic, assign) int userIndex; @property (nonatomic, assign) id popupDelegate; +- (void)setPersistentHighlight:(BOOL)persistentHighlight; @end @implementation NCAccountRow { NSView *_hoverView; + BOOL _mouseInside; + BOOL _persistentHighlight; } - (instancetype)init @@ -124,6 +201,17 @@ - (instancetype)init return self; } +- (void)updateHoverHighlight +{ + _hoverView.hidden = !(_mouseInside || _persistentHighlight); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + - (void)layout { [super layout]; @@ -132,12 +220,15 @@ - (void)layout - (void)mouseEntered:(NSEvent *)event { - _hoverView.hidden = NO; + _mouseInside = YES; + [self updateHoverHighlight]; + [self.popupDelegate onAccountRowHovered:self]; } - (void)mouseExited:(NSEvent *)event { - _hoverView.hidden = YES; + _mouseInside = NO; + [self updateHoverHighlight]; } - (void)mouseUp:(NSEvent *)event @@ -149,18 +240,100 @@ - (void)mouseUp:(NSEvent *)event @interface NCActionRow : NCHoverView - (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action; +- (instancetype)initWithTitle:(NSString *)title + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action; +- (instancetype)initWithTitle:(NSString *)title + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator; +- (void)setPersistentHighlight:(BOOL)persistentHighlight; @end @implementation NCActionRow { dispatch_block_t _action; + NCActionHoverBlock _hoverAction; NSView *_hoverView; + NSTextField *_label; + BOOL _actionEnabled; + BOOL _mouseInside; + BOOL _persistentHighlight; } - (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action +{ + return [self initWithTitle:title width:kPopupWidth enabled:YES action:action]; +} + +- (instancetype)initWithTitle:(NSString *)title + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action +{ + return [self initWithTitle:title icon:nil width:width enabled:enabled action:action hoverAction:nil]; +} + +- (instancetype)initWithTitle:(NSString *)title + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction +{ + return [self initWithTitle:title icon:nil width:width enabled:enabled action:action hoverAction:hoverAction showsSubmenuIndicator:NO]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action +{ + return [self initWithTitle:title icon:icon width:width enabled:enabled action:action hoverAction:nil]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction +{ + return [self initWithTitle:title icon:icon width:width enabled:enabled action:action hoverAction:hoverAction showsSubmenuIndicator:NO]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator { self = [super init]; if (!self) return nil; _action = [action copy]; + _hoverAction = [hoverAction copy]; + _actionEnabled = enabled; self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); _hoverView = [[NSView alloc] init]; @@ -170,21 +343,68 @@ - (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action _hoverView.hidden = YES; [self addSubview:_hoverView]; - NSTextField *label = [NSTextField labelWithString:title]; - label.font = [NSFont systemFontOfSize:13]; - label.textColor = NSColor.labelColor; - label.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:label]; + _label = [NSTextField labelWithString:title]; + _label.font = [NSFont systemFontOfSize:13]; + _label.textColor = enabled ? NSColor.labelColor : NSColor.tertiaryLabelColor; + _label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_label]; - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], - [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + auto constraints = [NSMutableArray arrayWithArray:@[ + [_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], [self.heightAnchor constraintEqualToConstant:kActionHeight], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], + [self.widthAnchor constraintEqualToConstant:width], ]]; + + if (showsSubmenuIndicator) { + NSImageView *chevron = [[NSImageView alloc] init]; + chevron.image = [[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:nil] + imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:11 weight:NSFontWeightMedium]]; + chevron.contentTintColor = enabled ? NSColor.tertiaryLabelColor : NSColor.quaternaryLabelColor; + chevron.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:chevron]; + [constraints addObjectsFromArray:@[ + [chevron.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-kHPad], + [chevron.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [chevron.widthAnchor constraintEqualToConstant:8], + [chevron.heightAnchor constraintEqualToConstant:13], + [_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8], + ]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } + + if (icon) { + NSImageView *iconView = [[NSImageView alloc] init]; + iconView.image = icon; + iconView.imageScaling = NSImageScaleProportionallyUpOrDown; + iconView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:iconView]; + [constraints addObjectsFromArray:@[ + [iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [iconView.widthAnchor constraintEqualToConstant:18.0], + [iconView.heightAnchor constraintEqualToConstant:18.0], + [_label.leadingAnchor constraintEqualToAnchor:iconView.trailingAnchor constant:8.0], + ]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } + + [NSLayoutConstraint activateConstraints:constraints]; return self; } +- (void)updateHoverHighlight +{ + _hoverView.hidden = !_actionEnabled || !(_mouseInside || _persistentHighlight); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + - (void)layout { [super layout]; @@ -193,47 +413,369 @@ - (void)layout - (void)mouseEntered:(NSEvent *)event { - _hoverView.hidden = NO; + _mouseInside = YES; + [self updateHoverHighlight]; + if (_actionEnabled && _hoverAction) _hoverAction(self); } - (void)mouseExited:(NSEvent *)event { - _hoverView.hidden = YES; + _mouseInside = NO; + [self updateHoverHighlight]; } - (void)mouseUp:(NSEvent *)event { - if (_action) _action(); + if (_actionEnabled && _action) _action(); } @end @interface NCSpacerView : NSView - (instancetype)initWithHeight:(CGFloat)height; +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width; @end @implementation NCSpacerView - (instancetype)initWithHeight:(CGFloat)height +{ + return [self initWithHeight:height width:kPopupWidth]; +} + +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width { self = [super init]; if (!self) return nil; self.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [self.heightAnchor constraintEqualToConstant:height], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], + [self.widthAnchor constraintEqualToConstant:width], ]]; return self; } @end +@class NCTrayPopup; + @interface NCTrayPopup : NSPanel - (void)populate; +- (void)closeAllPopups; +- (void)closeAccountActionsPopup; +- (void)clearActiveAccountRow; +- (void)openActivitiesForIndex:(int)index; +- (void)openLocalFolderForIndex:(int)index; +- (void)openOnlineStatusForIndex:(int)index; +@end + +@interface NCAppsPopup : NSPanel +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; +@end + +@implementation NCAppsPopup { + NSStackView *_stack; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kAppsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + auto appsModel = OCC::TrayAccountAppsModel::instance(); + appsModel->setUserId(userIndex); + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; + for (auto row = 0; row < appsModel->rowCount(); ++row) { + const auto appIndex = appsModel->index(row); + const auto appUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::UrlRole).toUrl(); + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:appsModel->data(appIndex, OCC::TrayAccountAppsModel::NameRole).toString().toNSString() + icon:nsImageFromQUrl(appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl()) + width:kAppsPopupWidth + enabled:YES + action:^{ + [weakOwner closeAllPopups]; + appsModel->openAppUrl(appUrl); + }]]; + } + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kAppsPopupWidth; + frame.size.height = _stack.fittingSize.height; + [self setFrame:frame display:NO]; + [self invalidateShadow]; +} + +@end + +@interface NCAccountActionsPopup : NSPanel +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; +- (void)clearActiveSubmenuRow; +- (void)hideAppsPopup; +@end + +@implementation NCAccountActionsPopup { + NSStackView *_stack; + NCAppsPopup *_appsPopup; + NCActionRow *_activeSubmenuRow; + __unsafe_unretained NCTrayPopup *_owner; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kAccountActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)orderOut:(id)sender +{ + [_appsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; + [super orderOut:sender]; +} + +- (void)clearActiveSubmenuRow +{ + [_activeSubmenuRow setPersistentHighlight:NO]; + _activeSubmenuRow = nil; +} + +- (void)hideAppsPopup +{ + [_appsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; +} + +- (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex +{ + if (!_appsPopup) { + _appsPopup = [[NCAppsPopup alloc] init]; + } + + [_appsPopup populateForUserIndex:userIndex owner:_owner]; + [self clearActiveSubmenuRow]; + if ([row isKindOfClass:[NCActionRow class]]) { + _activeSubmenuRow = (NCActionRow *)row; + [_activeSubmenuRow setPersistentHighlight:YES]; + } + + const auto rowTopLeftInWindow = [row convertPoint:NSMakePoint(NSMinX(row.bounds) + kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopRightInWindow = [row convertPoint:NSMakePoint(NSMaxX(row.bounds) - kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopLeftOnScreen = [row.window convertPointToScreen:rowTopLeftInWindow]; + auto rowTopRightOnScreen = [row.window convertPointToScreen:rowTopRightInWindow]; + + const auto popupWidth = _appsPopup.frame.size.width; + const auto popupHeight = _appsPopup.frame.size.height; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= popupHeight; + + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + const auto topEdge = NSMaxY(visibleFrame) - kScreenEdgePadding; + const auto bottomEdge = NSMinY(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + popupOrigin.y = popupOrigin.y < bottomEdge ? bottomEdge : (popupOrigin.y + popupHeight > topEdge ? topEdge - popupHeight : popupOrigin.y); + + [_appsPopup setFrameOrigin:popupOrigin]; + [_appsPopup orderFront:nil]; +} + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + _owner = owner; + [_appsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; + + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } + + auto model = OCC::UserModel::instance(); + const auto userModelIndex = model->index(userIndex); + const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() + && model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); + const auto status = model->data(userModelIndex, OCC::UserModel::StatusRole).value(); + const auto statusMessage = model->data(userModelIndex, OCC::UserModel::StatusMessageRole).toString(); + NSImage *statusIcon = nsImageFromQUrl(model->data(userModelIndex, OCC::UserModel::StatusIconRole).toUrl()); + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCAccountActionsPopup *weakSelf = self; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() + icon:statusIcon + width:kAccountActionsPopupWidth + enabled:onlineStatusEnabled + action:^{ + [weakOwner openOnlineStatusForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayFoldersMenuButtonText("Open local folder").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openLocalFolderForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:mainWindowText("Ask Assistant\302\240\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:NO + action:^{}]]; + + NSBox *separator = [[NSBox alloc] init]; + separator.boxType = NSBoxSeparator; + separator.translatesAutoresizingMaskIntoConstraints = NO; + [_stack addArrangedSubview:separator]; + [separator.widthAnchor constraintEqualToConstant:kAccountActionsPopupWidth].active = YES; + + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:fileDetailsPageText("Activity").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + auto appsModel = OCC::TrayAccountAppsModel::instance(); + appsModel->setUserId(userIndex); + const auto appsEnabled = appsModel->rowCount() > 0; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayWindowHeaderText("More apps").toNSString() + icon:nil + width:kAccountActionsPopupWidth + enabled:appsEnabled + action:^{} + hoverAction:^(NSView *row) { + [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; + } showsSubmenuIndicator:YES]]; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kAccountActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + [self setFrame:frame display:NO]; + [self invalidateShadow]; +} + @end @implementation NCTrayPopup { NSStackView *_stack; + NCAccountActionsPopup *_accountActionsPopup; + NCAccountRow *_activeAccountRow; } - (instancetype)init @@ -291,10 +833,32 @@ - (BOOL)canBecomeKeyWindow { return YES; } - (void)resignKeyWindow { [super resignKeyWindow]; + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); +} + +- (void)closeAllPopups +{ + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; [self orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); } +- (void)closeAccountActionsPopup +{ + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; +} + +- (void)clearActiveAccountRow +{ + [_activeAccountRow setPersistentHighlight:NO]; + _activeAccountRow = nil; +} + - (NCAccountRow *)makeRowForIndex:(int)index name:(NSString *)name server:(NSString *)server @@ -375,6 +939,9 @@ - (NCAccountRow *)makeRowForIndex:(int)index - (void)populate { + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + for (NSView *v in _stack.arrangedSubviews.copy) { [_stack removeArrangedSubview:v]; [v removeFromSuperview]; @@ -388,7 +955,11 @@ - (void)populate NSString *server = model->data(idx, OCC::UserModel::ServerRole).toString().toNSString(); NSImage *avatar = nsImageFromQImage(model->avatarForRow(i)); NSImage *syncStatus = nsImageFromQImage(model->syncStatusIconForRow(i)); - [_stack addArrangedSubview:[self makeRowForIndex:i name:name server:server avatar:avatar syncStatusImage:syncStatus]]; + [_stack addArrangedSubview:[self makeRowForIndex:i + name:name + server:server + avatar:avatar + syncStatusImage:syncStatus]]; } if (model->rowCount() > 0) { @@ -402,19 +973,34 @@ - (void)populate __unsafe_unretained NCTrayPopup *weakSelf = self; if (OCC::Systray::instance()->enableAddAccount()) { - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Add account").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Add account").toNSString() + width:kPopupWidth + enabled:YES + action:^{ [weakSelf orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); OCC::Systray::instance()->openAccountWizard(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; } - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Settings").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Settings").toNSString() + width:kPopupWidth + enabled:YES + action:^{ [weakSelf orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); OCC::Systray::instance()->openSettings(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() + width:kPopupWidth + enabled:YES + action:^{ OCC::Systray::instance()->shutdown(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]]; @@ -427,12 +1013,89 @@ - (void)populate - (void)onAccountRowClicked:(int)index { + [self openActivitiesForIndex:index]; +} + +- (void)onAccountRowHovered:(NCAccountRow *)row +{ + if (!_accountActionsPopup) { + _accountActionsPopup = [[NCAccountActionsPopup alloc] init]; + } + + if (_activeAccountRow != row) { + [self clearActiveAccountRow]; + _activeAccountRow = row; + [_activeAccountRow setPersistentHighlight:YES]; + } + + [_accountActionsPopup populateForUserIndex:row.userIndex owner:self]; + + const auto rowTopLeftInWindow = [row convertPoint:NSMakePoint(NSMinX(row.bounds) + kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopRightInWindow = [row convertPoint:NSMakePoint(NSMaxX(row.bounds) - kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopLeftOnScreen = [row.window convertPointToScreen:rowTopLeftInWindow]; + auto rowTopRightOnScreen = [row.window convertPointToScreen:rowTopRightInWindow]; + + const auto popupWidth = _accountActionsPopup.frame.size.width; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= _accountActionsPopup.frame.size.height; + + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + + [_accountActionsPopup setFrameOrigin:popupOrigin]; + [_accountActionsPopup orderFront:nil]; +} + +- (void)openActivitiesForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; [self orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); OCC::UserModel::instance()->setCurrentUserId(index); OCC::Systray::instance()->showQMLWindow(); } +- (void)openLocalFolderForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + + auto userModel = OCC::UserModel::instance(); + userModel->setCurrentUserId(index); + auto user = userModel->currentUser(); + if (!user) { + return; + } + + if (user->hasLocalFolder()) { + userModel->openCurrentAccountLocalFolder(); + } +#ifdef BUILD_FILE_PROVIDER_MODULE + else if (user->hasFileProvider()) { + userModel->openCurrentAccountFileProviderDomain(); + } +#endif +} + +- (void)openOnlineStatusForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + OCC::Systray::instance()->showUserStatusWindow(index); +} + @end namespace OCC { diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index f08ce524fa4ba..49ec01d47ca8e 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -33,6 +33,7 @@ #include "filedetails/sortedsharemodel.h" #include "tray/sortedactivitylistmodel.h" #include "tray/syncstatussummary.h" +#include "tray/trayaccountappsmodel.h" #include "tray/unifiedsearchresultslistmodel.h" #include "integration/fileactionsmodel.h" #include "filesystem.h" @@ -169,6 +170,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserModel", UserModel::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserAppsModel", UserAppsModel::instance()); + qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "TrayAccountAppsModel", TrayAccountAppsModel::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "Theme", Theme::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "Systray", Systray::instance()); diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index d9641b09be0fb..89f0de6856217 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -18,6 +18,10 @@ #include "callstatechecker.h" #include "guiutility.h" +#ifdef Q_OS_MACOS +#include "foregroundbackground_interface.h" +#endif + #include #include #include @@ -241,6 +245,81 @@ void Systray::showQMLWindow() UserModel::instance()->fetchCurrentActivityModel(); } +void Systray::showUserStatusWindow(int userIndex) +{ + const auto userModel = UserModel::instance(); + if (!userModel || userIndex < 0 || userIndex >= userModel->rowCount()) { + qCWarning(lcSystray) << "Invalid user index for user status window:" << userIndex; + return; + } + + const auto userModelIndex = userModel->index(userIndex); + if (!userModel->isUserConnected(userIndex) + || !userModel->data(userModelIndex, UserModel::ServerHasUserStatusRole).toBool()) { + qCDebug(lcSystray) << "Not opening user status window for disconnected or unsupported account:" << userIndex; + return; + } + + hideWindow(); + + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open user status window as no tray engine was available"; + return; + } + + if (_userStatusWindow) { + _userStatusWindow->setProperty("userIndex", userIndex); + positionWindowAtScreenCenter(_userStatusWindow.data()); + _userStatusWindow->show(); + _userStatusWindow->raise(); + _userStatusWindow->requestActivate(); + return; + } + + const QVariantMap initialProperties{ + {"userIndex", userIndex}, + }; + QQmlComponent userStatusWindowComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/UserStatusWindow.qml")); + + if (userStatusWindowComponent.isError()) { + qCWarning(lcSystray) << userStatusWindowComponent.errorString(); + qCWarning(lcSystray) << userStatusWindowComponent.errors(); + return; + } + + const auto createdObject = userStatusWindowComponent.createWithInitialProperties(initialProperties); + const auto window = qobject_cast(createdObject); + if (!window) { + qCWarning(lcSystray) << "User status window resulted in creation of object that was not a window!"; + if (createdObject) { + createdObject->deleteLater(); + } + return; + } + + _userStatusWindow = window; + _userStatusWindow->setIcon(Theme::instance()->applicationIcon()); + +#ifdef Q_OS_MACOS + auto *fgbg = new ForegroundBackground(this); + _userStatusWindow->installEventFilter(fgbg); +#endif + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + _userStatusWindow->setFlag(Qt::ExpandedClientAreaHint, true); + _userStatusWindow->setFlag(Qt::NoTitleBarBackgroundHint, true); +#endif + + connect(_userStatusWindow.data(), &QObject::destroyed, this, [this] { + _userStatusWindow = nullptr; + }); + + positionWindowAtScreenCenter(_userStatusWindow.data()); + _userStatusWindow->show(); + _userStatusWindow->raise(); + _userStatusWindow->requestActivate(); +} + void Systray::setupContextMenu() { const auto oldContextMenu = _contextMenu.data(); diff --git a/src/gui/systray.h b/src/gui/systray.h index 15ecfb1998b4c..629b0fdd4d499 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -147,6 +147,7 @@ public slots: void showWindow(OCC::Systray::WindowPosition position = OCC::Systray::WindowPosition::Default); void hideWindow(); void showQMLWindow(); + void showUserStatusWindow(int userIndex); void setSyncIsPaused(const bool syncIsPaused); void setIsOpen(const bool isOpen); @@ -212,6 +213,7 @@ private slots: std::unique_ptr _trayEngine; QPointer _contextMenu; QSharedPointer _trayWindow; + QPointer _userStatusWindow; #ifndef Q_OS_MACOS QSharedPointer _popupWindow; #endif diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index f058d6517b296..5db9f45865231 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -6,10 +6,12 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Window import Qt5Compat.GraphicalEffects import Style import com.nextcloud.desktopclient +import com.nextcloud.desktopclient as NC // Keep behavior and layout aligned with src/gui/macOS/trayaccountpopup_mac.mm. @@ -28,6 +30,7 @@ Window { property bool _closing: false property bool _hadFocusSinceShow: false + property var activeAccountActionsMenu: null onVisibleChanged: { if (visible) { @@ -44,6 +47,17 @@ Window { _closing = false } + function closeActiveAccountActionsMenu() { + if (activeAccountActionsMenu && activeAccountActionsMenu.opened) { + activeAccountActionsMenu.close() + } + activeAccountActionsMenu = null + } + + function translatedAskAssistantText() { + return qsTranslate("MainWindow", "Ask Assistant\u00A0…") + } + Rectangle { id: popupContainer anchors.fill: parent @@ -77,6 +91,13 @@ Window { delegate: ItemDelegate { id: accountRow + readonly property int userId: model.id + readonly property int onlineStatus: model.status + readonly property bool onlineStatusEnabled: model.isConnected && model.serverHasUserStatus + readonly property string statusIcon: model.statusIcon + readonly property string statusMessage: model.statusMessage + readonly property bool menuHighlighted: hovered || accountActionsMenu.opened + width: root.width height: Style.trayAccountPopupRowHeight hoverEnabled: true @@ -88,6 +109,78 @@ Window { leftPadding: Style.trayAccountPopupRowPadding rightPadding: Style.trayAccountPopupRowPadding + function openActivities() { + root._closing = true + UserModel.currentUserId = accountRow.userId + Systray.showQMLWindow() + } + + function openLocalFolder() { + root._closing = true + UserModel.currentUserId = accountRow.userId + Systray.hideWindow() + if (UserModel.currentUser && UserModel.currentUser.hasLocalFolder) { + UserModel.openCurrentAccountLocalFolder() + } else if (Qt.platform.os === "osx" + && UserModel.currentUser + && UserModel.currentUser.hasFileProvider) { + UserModel.openCurrentAccountFileProviderDomain() + } + } + + function currentStatusText() { + switch (onlineStatus) { + case NC.userStatus.Away: + return qsTranslate("UserStatusSetStatusView", "Away") + case NC.userStatus.Busy: + return qsTranslate("UserStatusSetStatusView", "Busy") + case NC.userStatus.DoNotDisturb: + return qsTranslate("UserStatusSetStatusView", "Do not disturb") + case NC.userStatus.Invisible: + return qsTranslate("UserStatusSetStatusView", "Invisible") + case NC.userStatus.Offline: + return qsTranslate("OCC::SyncStatusSummary", "Offline") + case NC.userStatus.Online: + default: + return qsTranslate("UserStatusSetStatusView", "Online") + } + } + + function currentStatusLabelText() { + var message = statusMessage.trim() + return message !== "" ? message : currentStatusText() + } + + function openAccountActionsMenu() { + TrayAccountAppsModel.setUserId(accountRow.userId) + root.closeActiveAccountActionsMenu() + + var rightAlignedX = Math.max(Style.trayAccountPopupHoverMargin, + accountRow.width - accountActionsMenu.width - Style.trayAccountPopupHoverMargin) + var leftAlignedX = Style.trayAccountPopupHoverMargin + var rowPosition = accountRow.mapToItem(popupContainer, 0, 0) + var screenLeft = root.screen && root.screen.virtualX !== undefined ? root.screen.virtualX : root.x + var screenWidth = root.screen && root.screen.width !== undefined ? root.screen.width : root.width + var screenRight = screenLeft + screenWidth + var rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + accountActionsMenu.width + + var menuX = rightAlignedScreenRight > screenRight - Style.trayAccountPopupHoverMargin + && root.x + rowPosition.x + leftAlignedX >= screenLeft + Style.trayAccountPopupHoverMargin + ? leftAlignedX + : rightAlignedX + + accountActionsMenu.popup(accountRow, + menuX, + Style.trayAccountPopupAccountHoverVerticalMargin) + root.activeAccountActionsMenu = accountActionsMenu + } + + onHoveredChanged: { + if (hovered && !accountActionsMenu.opened) { + openAccountActionsMenu() + } + } + background: Item { Rectangle { anchors.left: parent.left @@ -99,11 +192,213 @@ Window { anchors.topMargin: Style.trayAccountPopupAccountHoverVerticalMargin anchors.bottomMargin: Style.trayAccountPopupAccountHoverVerticalMargin radius: Style.trayAccountPopupHoverRadius - color: accountRow.hovered ? root.rowHoverColor : "transparent" + color: accountRow.menuHighlighted ? root.rowHoverColor : "transparent" Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } } + AutoSizingMenu { + id: accountActionsMenu + + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + height: implicitHeight + onClosed: { + appsMenu.close() + if (root.activeAccountActionsMenu === accountActionsMenu) { + root.activeAccountActionsMenu = null + } + } + + function closeAppsMenu() { + if (appsMenu.opened) { + appsMenu.close() + } + } + + MenuItem { + id: statusButton + + enabled: accountRow.onlineStatusEnabled + text: accountRow.currentStatusLabelText() + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeAppsMenu() + } + } + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + visible: statusButton.enabled + source: statusButton.enabled ? accountRow.statusIcon : "" + sourceSize.width: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + sourceSize.height: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + cache: false + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: statusButton.text + font: statusButton.font + color: statusButton.enabled ? palette.windowText : palette.mid + elide: Text.ElideRight + } + } + onClicked: { + root._closing = true + Systray.showUserStatusWindow(accountRow.userId) + } + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: statusButton.clicked() + } + + MenuItem { + id: openLocalFolderButton + + text: qsTranslate("TrayFoldersMenuButton", "Open local folder") + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeAppsMenu() + } + } + onClicked: accountRow.openLocalFolder() + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: openLocalFolderButton.clicked() + } + + MenuItem { + id: assistantButton + + enabled: false + text: root.translatedAskAssistantText() + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeAppsMenu() + } + } + + Accessible.role: Accessible.Button + Accessible.name: text + } + + MenuSeparator { + } + + MenuItem { + id: activitiesButton + + text: qsTranslate("FileDetailsPage", "Activity") + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeAppsMenu() + } + } + onClicked: accountRow.openActivities() + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: activitiesButton.clicked() + } + + MenuItem { + id: appsButton + + text: qsTranslate("TrayWindowHeader", "More apps") + enabled: TrayAccountAppsModel.count > 0 + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + + function openAppsMenu() { + if (!enabled) { + return + } + TrayAccountAppsModel.setUserId(accountRow.userId) + if (!appsMenu.opened) { + appsMenu.popup(appsButton, appsButton.width, 0) + } + } + + onHoveredChanged: { + if (hovered) { + openAppsMenu() + } + } + + onClicked: openAppsMenu() + + background: Rectangle { + color: appsButton.hovered || appsMenu.opened ? root.rowHoverColor : "transparent" + } + + contentItem: RowLayout { + spacing: 8 + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: appsButton.text + font: appsButton.font + color: appsButton.enabled ? palette.windowText : palette.mid + elide: Text.ElideRight + } + + EnforcedPlainTextLabel { + text: "›" + font.pixelSize: Style.trayAccountPopupChevronFontSize + color: appsButton.enabled ? palette.windowText : palette.mid + opacity: appsButton.enabled ? 0.35 : 1.0 + } + } + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: appsButton.clicked() + } + + AutoSizingMenu { + id: appsMenu + + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + + Repeater { + model: TrayAccountAppsModel + + delegate: MenuItem { + id: appEntry + + text: " " + model.appName + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + icon.source: "image://tray-image-provider/" + model.appIconUrl + icon.color: palette.windowText + onTriggered: { + root._closing = true + appsMenu.close() + accountActionsMenu.close() + Systray.hideWindow() + TrayAccountAppsModel.openAppUrl(appUrl) + } + + Accessible.role: Accessible.MenuItem + Accessible.name: qsTr("Open %1 in browser").arg(model.appName) + Accessible.onPressAction: appEntry.triggered() + } + } + } + } + contentItem: RowLayout { spacing: Style.trayAccountPopupRowSpacing @@ -165,9 +460,7 @@ Window { } onClicked: { - root._closing = true - UserModel.currentUserId = model.id - Systray.showQMLWindow() + accountRow.openActivities() } } } @@ -219,6 +512,12 @@ Window { verticalAlignment: Text.AlignVCenter } + onHoveredChanged: { + if (hovered) { + root.closeActiveAccountActionsMenu() + } + } + onClicked: { root._closing = true Systray.hideWindow() @@ -259,6 +558,12 @@ Window { verticalAlignment: Text.AlignVCenter } + onHoveredChanged: { + if (hovered) { + root.closeActiveAccountActionsMenu() + } + } + onClicked: { root._closing = true Systray.hideWindow() @@ -299,6 +604,12 @@ Window { verticalAlignment: Text.AlignVCenter } + onHoveredChanged: { + if (hovered) { + root.closeActiveAccountActionsMenu() + } + } + onClicked: { root._closing = true Systray.shutdown() diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp new file mode 100644 index 0000000000000..54c8c19c20005 --- /dev/null +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "trayaccountappsmodel.h" + +#include "accountmanager.h" +#include "account.h" +#include "guiutility.h" + +namespace OCC { + +TrayAccountAppsModel *TrayAccountAppsModel::_instance = nullptr; + +TrayAccountAppsModel *TrayAccountAppsModel::instance() +{ + if (!_instance) { + _instance = new TrayAccountAppsModel(); + } + return _instance; +} + +TrayAccountAppsModel::TrayAccountAppsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +void TrayAccountAppsModel::setUserId(const int userId) +{ + const auto oldCount = _apps.size(); + + if (!_apps.isEmpty()) { + beginRemoveRows(QModelIndex(), 0, _apps.size() - 1); + _apps.clear(); + endRemoveRows(); + } + + const auto accounts = AccountManager::instance()->accounts(); + if (userId < 0 || userId >= accounts.size()) { + if (oldCount != _apps.size()) { + emit countChanged(); + } + return; + } + + const auto account = accounts.at(userId); + if (!account) { + if (oldCount != _apps.size()) { + emit countChanged(); + } + return; + } + + const auto allApps = account->appList(); + const auto talkApp = account->findApp(QStringLiteral("spreed")); + const auto assistantEnabled = account->account()->capabilities().ncAssistantEnabled(); + for (const auto app : allApps) { + // Filter out Talk because we have a dedicated button for it. + if (talkApp && app->id() == talkApp->id() && !assistantEnabled) { + continue; + } + + beginInsertRows(QModelIndex(), _apps.size(), _apps.size()); + _apps << app; + endInsertRows(); + } + + if (_apps.size() != oldCount) { + emit countChanged(); + } +} + +void TrayAccountAppsModel::openAppUrl(const QUrl &url) +{ + Utility::openBrowser(url); +} + +int TrayAccountAppsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return count(); +} + +int TrayAccountAppsModel::count() const +{ + return _apps.size(); +} + +QVariant TrayAccountAppsModel::data(const QModelIndex &index, const int role) const +{ + if (index.row() < 0 || index.row() >= _apps.size()) { + return {}; + } + + switch (role) { + case NameRole: + return _apps[index.row()]->name(); + case UrlRole: + return _apps[index.row()]->url(); + case IconUrlRole: + return _apps[index.row()]->iconUrl().toString(); + default: + return {}; + } +} + +QHash TrayAccountAppsModel::roleNames() const +{ + return { + { NameRole, "appName" }, + { UrlRole, "appUrl" }, + { IconUrlRole, "appIconUrl" }, + }; +} + +} // namespace OCC diff --git a/src/gui/tray/trayaccountappsmodel.h b/src/gui/tray/trayaccountappsmodel.h new file mode 100644 index 0000000000000..0c91aa8eee818 --- /dev/null +++ b/src/gui/tray/trayaccountappsmodel.h @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef TRAYACCOUNTAPPSMODEL_H +#define TRAYACCOUNTAPPSMODEL_H + +#include "accountstate.h" + +#include +#include +#include + +namespace OCC { + +class TrayAccountAppsModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) +public: + static TrayAccountAppsModel *instance(); + ~TrayAccountAppsModel() override = default; + + enum Roles { + NameRole = Qt::UserRole + 1, + UrlRole, + IconUrlRole + }; + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] int count() const; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + Q_INVOKABLE void setUserId(int userId); + Q_INVOKABLE void openAppUrl(const QUrl &url); + +signals: + void countChanged(); + +protected: + [[nodiscard]] QHash roleNames() const override; + +private: + explicit TrayAccountAppsModel(QObject *parent = nullptr); + + static TrayAccountAppsModel *_instance; + AccountAppList _apps; +}; + +} // namespace OCC + +#endif // TRAYACCOUNTAPPSMODEL_H diff --git a/src/gui/userstatusselectormodel.cpp b/src/gui/userstatusselectormodel.cpp index 2a1d3b8c937c4..9c3cda3577cab 100644 --- a/src/gui/userstatusselectormodel.cpp +++ b/src/gui/userstatusselectormodel.cpp @@ -98,6 +98,7 @@ void UserStatusSelectorModel::reset() &UserStatusSelectorModel::onMessageCleared); } _userStatusConnector = nullptr; + _setUserStatusOperations.clear(); } void UserStatusSelectorModel::init() @@ -126,6 +127,18 @@ void UserStatusSelectorModel::init() void UserStatusSelectorModel::onUserStatusSet() { + auto operation = SetUserStatusOperation::Message; + if (!_setUserStatusOperations.empty()) { + operation = _setUserStatusOperations.front(); + _setUserStatusOperations.pop_front(); + } else if (!_finishOnOnlineStatusSet) { + return; + } + + if (operation == SetUserStatusOperation::OnlineStatus && !_finishOnOnlineStatusSet) { + return; + } + emit finished(); } @@ -156,6 +169,9 @@ void UserStatusSelectorModel::onError(UserStatusConnector::Error error) return; case UserStatusConnector::Error::CouldNotSetUserStatus: + if (!_setUserStatusOperations.empty()) { + _setUserStatusOperations.pop_front(); + } setError(tr("Could not set status. Make sure you are connected to the server.")); return; @@ -178,6 +194,21 @@ void UserStatusSelectorModel::clearError() setError(""); } +bool UserStatusSelectorModel::finishOnOnlineStatusSet() const +{ + return _finishOnOnlineStatusSet; +} + +void UserStatusSelectorModel::setFinishOnOnlineStatusSet(bool finishOnOnlineStatusSet) +{ + if (_finishOnOnlineStatusSet == finishOnOnlineStatusSet) { + return; + } + + _finishOnOnlineStatusSet = finishOnOnlineStatusSet; + emit finishOnOnlineStatusSetChanged(); +} + void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) { if (!_userStatusConnector || status == _userStatus.state()) { @@ -185,6 +216,7 @@ void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) } _userStatus.setState(status); + _setUserStatusOperations.push_back(SetUserStatusOperation::OnlineStatus); _userStatusConnector->setUserStatus(_userStatus); emit userStatusChanged(); } @@ -307,6 +339,7 @@ void UserStatusSelectorModel::setUserStatus() } clearError(); + _setUserStatusOperations.push_back(SetUserStatusOperation::Message); _userStatusConnector->setUserStatus(_userStatus); } diff --git a/src/gui/userstatusselectormodel.h b/src/gui/userstatusselectormodel.h index 424925f555b12..ae5f8cc511819 100644 --- a/src/gui/userstatusselectormodel.h +++ b/src/gui/userstatusselectormodel.h @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -34,6 +35,7 @@ class UserStatusSelectorModel : public QObject Q_PROPERTY(QString clearAtDisplayString READ clearAtDisplayString NOTIFY clearAtDisplayStringChanged) Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) Q_PROPERTY(bool busyStatusSupported READ busyStatusSupported NOTIFY busyStatusSupportedChanged) + Q_PROPERTY(bool finishOnOnlineStatusSet READ finishOnOnlineStatusSet WRITE setFinishOnOnlineStatusSet NOTIFY finishOnOnlineStatusSetChanged) Q_PROPERTY(QUrl onlineIcon READ onlineIcon CONSTANT) Q_PROPERTY(QUrl awayIcon READ awayIcon CONSTANT) Q_PROPERTY(QUrl dndIcon READ dndIcon CONSTANT) @@ -91,9 +93,11 @@ class UserStatusSelectorModel : public QObject [[nodiscard]] QString errorMessage() const; [[nodiscard]] bool busyStatusSupported() const; + [[nodiscard]] bool finishOnOnlineStatusSet() const; public slots: void setUserIndex(const int userIndex); + void setFinishOnOnlineStatusSet(bool finishOnOnlineStatusSet); void setUserStatus(); void clearUserStatus(); void setClearAt(const OCC::UserStatusSelectorModel::ClearStageType clearStageType); @@ -106,9 +110,15 @@ public slots: void userStatusChanged(); void clearAtDisplayStringChanged(); void predefinedStatusesChanged(); + void finishOnOnlineStatusSetChanged(); void finished(); private: + enum class SetUserStatusOperation { + OnlineStatus, + Message, + }; + void init(); void reset(); void onUserStatusFetched(const UserStatus &userStatus); @@ -125,10 +135,12 @@ public slots: void clearError(); int _userIndex = -1; + bool _finishOnOnlineStatusSet = true; std::shared_ptr _userStatusConnector {}; QVector _predefinedStatuses; UserStatus _userStatus; std::unique_ptr _dateTimeProvider; + std::deque _setUserStatusOperations; QString _errorMessage; diff --git a/test/testsetuserstatusdialog.cpp b/test/testsetuserstatusdialog.cpp index 92512fa676cd7..7425fef3916d8 100644 --- a/test/testsetuserstatusdialog.cpp +++ b/test/testsetuserstatusdialog.cpp @@ -44,6 +44,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector void setUserStatus(const OCC::UserStatus &userStatus) override { + ++_setUserStatusCallCount; if (_couldNotSetUserStatusMessage) { emit error(Error::CouldNotSetUserStatus); return; @@ -59,6 +60,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector emit error(Error::CouldNotClearMessage); } else { _isMessageCleared = true; + emit UserStatusConnector::messageCleared(); } } @@ -84,9 +86,15 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector } [[nodiscard]] OCC::UserStatus userStatusSetByCallerOfSetUserStatus() const { return _userStatusSetByCallerOfSetUserStatus; } + [[nodiscard]] int setUserStatusCallCount() const { return _setUserStatusCallCount; } [[nodiscard]] bool messageCleared() const { return _isMessageCleared; } + void emitUserStatusSet() + { + emit UserStatusConnector::userStatusSet(); + } + void setErrorCouldNotFetchPredefinedUserStatuses(bool value) { _couldNotFetchPredefinedUserStatuses = value; @@ -121,6 +129,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector OCC::UserStatus _userStatusSetByCallerOfSetUserStatus; OCC::UserStatus _userStatus; QVector _predefinedStatuses; + int _setUserStatusCallCount = 0; bool _isMessageCleared = false; bool _couldNotFetchPredefinedUserStatuses = false; bool _couldNotFetchUserStatus = false; @@ -277,6 +286,87 @@ private slots: QCOMPARE(userStatusChangedSpy.count(), 1); } + void testSetOnlineStatus_defaultEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(finishedSpy.count(), 1); + } + + void testSetOnlineStatus_finishOnOnlineStatusSetFalseDoesNotEmitFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(fakeUserStatusJob->userStatusSetByCallerOfSetUserStatus().state(), + OCC::UserStatus::OnlineStatus::Away); + QCOMPARE(finishedSpy.count(), 0); + } + + void testSetOnlineStatus_finishOnOnlineStatusSetFalseIgnoresExtraFinishedSignal() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + fakeUserStatusJob->emitUserStatusSet(); + + QCOMPARE(finishedSpy.count(), 0); + } + + void testSetUserStatus_finishOnOnlineStatusSetFalseStillEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setUserStatusMessage(QStringLiteral("Some status")); + model.setUserStatus(); + + QCOMPARE(finishedSpy.count(), 1); + } + + void testClearUserStatus_finishOnOnlineStatusSetFalseStillEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.clearUserStatus(); + + QVERIFY(fakeUserStatusJob->messageCleared()); + QCOMPARE(finishedSpy.count(), 1); + } + + void testSetOnlineStatus_errorDoesNotEmitFinished() + { + auto fakeUserStatusJob = std::make_shared(); + fakeUserStatusJob->setErrorCouldNotSetUserStatusMessage(true); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(model.errorMessage(), QStringLiteral("Could not set status. Make sure you are connected to the server.")); + QCOMPARE(finishedSpy.count(), 0); + } + void testSetUserStatus_setCustomMessage_userStatusSetCorrect() { auto fakeUserStatusJob = std::make_shared(); From 5d235c71060d83cdd9a4538e3d62c93efa588bfc Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 18 Jun 2026 21:27:21 +0200 Subject: [PATCH 02/20] fix(UI): implementing further design improvements Signed-off-by: Rello --- resources.qrc | 3 + src/gui/ActivitiesWindow.qml | 182 +++++ src/gui/AssistantWindow.qml | 279 +++++++ src/gui/UserStatusWindow.qml | 12 +- src/gui/WindowAccountHeader.qml | 98 +++ src/gui/macOS/trayaccountpopup_mac.mm | 921 +++++++++++++++++++++-- src/gui/systray.cpp | 226 +++++- src/gui/systray.h | 6 + src/gui/tray/ActivityItemActions.qml | 2 +- src/gui/tray/MainWindow.qml | 7 +- src/gui/tray/SyncStatus.qml | 33 +- src/gui/tray/TrayAccountPopup.qml | 713 +++++++++++++++--- src/gui/tray/activitylistmodel.cpp | 515 ++++++++++++- src/gui/tray/activitylistmodel.h | 12 +- src/gui/tray/asyncimageresponse.cpp | 30 +- src/gui/tray/syncstatussummary.cpp | 11 +- src/gui/tray/syncstatussummary.h | 3 + src/gui/tray/trayaccountappsmodel.cpp | 42 +- src/gui/tray/trayaccountappsmodel.h | 3 + src/gui/tray/usermodel.cpp | 380 +++++++++- src/gui/tray/usermodel.h | 25 + src/gui/wizard/qml/WizardDialogFrame.qml | 8 +- theme/Style/Style.qml | 41 +- 23 files changed, 3305 insertions(+), 247 deletions(-) create mode 100644 src/gui/ActivitiesWindow.qml create mode 100644 src/gui/AssistantWindow.qml create mode 100644 src/gui/WindowAccountHeader.qml diff --git a/resources.qrc b/resources.qrc index b56d2b29df61f..8d90e8a125b6f 100644 --- a/resources.qrc +++ b/resources.qrc @@ -2,6 +2,9 @@ src/gui/UserStatusMessageView.qml src/gui/UserStatusWindow.qml + src/gui/WindowAccountHeader.qml + src/gui/ActivitiesWindow.qml + src/gui/AssistantWindow.qml src/gui/UserStatusWindowStatusRow.qml src/gui/UserStatusWindowPredefinedStatusRow.qml src/gui/UserStatusSelectorPage.qml diff --git a/src/gui/ActivitiesWindow.qml b/src/gui/ActivitiesWindow.qml new file mode 100644 index 0000000000000..a934fd623f77e --- /dev/null +++ b/src/gui/ActivitiesWindow.qml @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Style +import "./tray" + +ApplicationWindow { + id: root + + property int userIndex: -1 + property var currentUser: null + property var activityModel: null + readonly property string headline: qsTr("Activities") + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + title: "" + width: Style.activitiesWindowWidth + height: Style.activitiesWindowHeight + minimumWidth: Style.wizardStandaloneWindowMinimumWidth + minimumHeight: Style.wizardStandaloneWindowMinimumHeight + flags: Qt.Window + | Qt.CustomizeWindowHint + | Qt.WindowTitleHint + | Qt.WindowSystemMenuHint + | Qt.WindowCloseButtonHint + color: Style.wizardWindowBackground + palette.window: Style.wizardWindowBackground + palette.windowText: Style.wizardPrimaryText + palette.base: Style.wizardFieldBackground + palette.text: Style.wizardPrimaryText + palette.button: Style.wizardFieldBackground + palette.buttonText: Style.wizardPrimaryText + palette.mid: Style.wizardDisabledText + palette.placeholderText: Style.wizardPlaceholderText + + background: Rectangle { + color: Style.wizardWindowBackground + } + + function reloadForCurrentUser() { + newActivitiesButtonLoader.active = false + syncStatus.model.loadForUser(root.currentUser) + } + + Shortcut { + sequences: [StandardKey.Cancel] + onActivated: root.close() + } + + Component.onCompleted: reloadForCurrentUser() + + onVisibleChanged: { + if (visible) { + reloadForCurrentUser() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Style.wizardWindowMargin + anchors.rightMargin: Style.wizardWindowMargin + anchors.topMargin: Style.wizardWindowTopMargin + anchors.bottomMargin: Style.wizardWindowMargin + spacing: Style.wizardSectionSpacing + + WindowAccountHeader { + Layout.fillWidth: true + title: root.headline + user: root.currentUser + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.wizardRowBorder + } + + SyncStatus { + id: syncStatus + + Layout.fillWidth: true + accentColor: Style.accentColor + user: root.currentUser + activityListModel: root.activityModel + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Style.normalBorderWidth + color: Style.wizardRowBorder + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ActivityList { + id: activityList + + anchors.fill: parent + activeFocusOnTab: true + model: root.activityModel + onOpenFile: Qt.openUrlExternally(filePath) + onActivityItemClicked: { + if (root.activityModel) { + root.activityModel.slotTriggerDefaultAction(index) + } + } + + Connections { + target: root.activityModel + + function onInteractiveActivityReceived() { + if (!activityList.atYBeginning) { + newActivitiesButtonLoader.active = true + } + } + } + } + + Loader { + id: newActivitiesButtonLoader + + anchors.top: activityList.top + anchors.topMargin: Style.smallSpacing + anchors.horizontalCenter: activityList.horizontalCenter + width: Style.newActivitiesButtonWidth + height: Style.newActivitiesButtonHeight + z: 1 + active: false + + sourceComponent: Button { + id: newActivitiesButton + + anchors.fill: parent + hoverEnabled: true + padding: Style.smallSpacing + text: qsTr("New activities") + icon.source: "image://svgimage-custom-color/expand-less-black.svg/" + Style.currentUserHeaderTextColor + icon.width: Style.activityListButtonIconSize + icon.height: Style.activityListButtonIconSize + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: newActivitiesButton.clicked() + onClicked: { + activityList.scrollToTop() + newActivitiesButtonLoader.active = false + } + + Timer { + id: newActivitiesButtonDisappearTimer + + interval: Style.newActivityButtonDisappearTimeout + running: newActivitiesButtonLoader.active && !newActivitiesButton.hovered + repeat: false + onTriggered: fadeoutActivitiesButtonDisappear.running = true + } + + OpacityAnimator { + id: fadeoutActivitiesButtonDisappear + + target: newActivitiesButton + from: 1 + to: 0 + duration: Style.newActivityButtonDisappearFadeTimeout + loops: 1 + running: false + onFinished: newActivitiesButtonLoader.active = false + } + } + } + } + } +} diff --git a/src/gui/AssistantWindow.qml b/src/gui/AssistantWindow.qml new file mode 100644 index 0000000000000..ee9e9024aeb5e --- /dev/null +++ b/src/gui/AssistantWindow.qml @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import "./tray" +import "./wizard/qml" + +ApplicationWindow { + id: root + + property int userIndex: -1 + property var currentUser: null + readonly property string headline: qsTr("Nextcloud Assistant") + readonly property bool hasAssistantConversation: currentUser !== null + && (currentUser.assistantMessages.length > 0 + || currentUser.assistantResponse.length > 0 + || currentUser.assistantError.length > 0) + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + title: "" + width: Style.assistantWindowWidth + height: Style.assistantWindowHeight + minimumWidth: Style.wizardStandaloneWindowMinimumWidth + minimumHeight: Style.wizardStandaloneWindowMinimumHeight + flags: Qt.Window + | Qt.CustomizeWindowHint + | Qt.WindowTitleHint + | Qt.WindowSystemMenuHint + | Qt.WindowCloseButtonHint + color: Style.wizardWindowBackground + palette.window: Style.wizardWindowBackground + palette.windowText: Style.wizardPrimaryText + palette.base: Style.wizardFieldBackground + palette.text: Style.wizardPrimaryText + palette.button: Style.wizardFieldBackground + palette.buttonText: Style.wizardPrimaryText + palette.mid: Style.wizardDisabledText + palette.placeholderText: Style.wizardPlaceholderText + + background: Rectangle { + color: Style.wizardWindowBackground + } + + function submitQuestion() { + if (!currentUser) { + return + } + + const question = assistantQuestionInput.text.trim() + if (question.length === 0) { + return + } + + currentUser.submitAssistantQuestion(question) + assistantQuestionInput.text = "" + } + + function resetAssistantConversation() { + if (!currentUser) { + return + } + + currentUser.clearAssistantResponse() + assistantQuestionInput.text = "" + assistantQuestionInput.forceActiveFocus() + } + + Shortcut { + sequences: [StandardKey.Cancel] + onActivated: root.close() + } + + Connections { + target: root.currentUser + + function onAssistantStateChanged() { + if (root.currentUser && !root.currentUser.isAssistantEnabled) { + root.close() + } + } + } + + Dialog { + id: resetConfirmationDialog + + modal: true + width: Math.min(Style.wizardDialogMaximumWidth, root.width - Style.wizardWindowMargin * 2) + padding: Style.wizardWindowMargin + x: Math.round((root.width - width) / 2) + y: Math.round((root.height - height) / 2) + header: null + footer: null + + background: Rectangle { + radius: Style.wizardDialogRadius + color: Style.wizardWindowBackground + border.width: Style.normalBorderWidth + border.color: Style.wizardFieldBorder + } + + contentItem: ColumnLayout { + spacing: Style.wizardDialogSpacing + Accessible.role: Accessible.Dialog + Accessible.name: qsTr("Start new conversation?") + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: qsTr("Start new conversation?") + color: Style.wizardPrimaryText + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.bold: true + wrapMode: Text.WordWrap + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: qsTr("This will clear the existing conversation.") + color: Style.wizardSecondaryText + font.pixelSize: Style.wizardBodyFontPixelSize + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.wizardFooterSpacing + + Item { + Layout.fillWidth: true + } + + WizardButton { + text: qsTr("Cancel") + onClicked: resetConfirmationDialog.close() + } + + WizardButton { + primary: true + text: qsTr("New conversation") + onClicked: { + resetConfirmationDialog.close() + root.resetAssistantConversation() + } + } + } + } + } + + WizardDialogFrame { + id: frame + + anchors.fill: parent + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: frame.windowMargin + anchors.rightMargin: frame.windowMargin + anchors.topMargin: Style.wizardWindowTopMargin + anchors.bottomMargin: frame.windowMargin + spacing: Style.wizardSectionSpacing + + WindowAccountHeader { + Layout.fillWidth: true + title: root.headline + user: root.currentUser + } + + ListView { + id: assistantConversationList + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Style.wizardSectionSpacing + boundsBehavior: Flickable.StopAtBounds + model: root.currentUser ? root.currentUser.assistantMessages : [] + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Item { + id: messageDelegate + + required property var modelData + + readonly property bool isAssistantMessage: modelData.role === "assistant" + + width: assistantConversationList.width + implicitHeight: messageBubble.implicitHeight + + Rectangle { + id: messageBubble + + anchors.left: messageDelegate.isAssistantMessage ? parent.left : undefined + anchors.right: messageDelegate.isAssistantMessage ? undefined : parent.right + anchors.leftMargin: 2 + anchors.rightMargin: 2 + radius: 10 + color: messageDelegate.isAssistantMessage ? Style.wizardRowBackground : Style.wizardPrimaryButtonBackground + width: Math.min(messageDelegate.width * 0.78, Math.max(120, messageText.implicitWidth + 24)) + implicitHeight: messageText.implicitHeight + 20 + + TextEdit { + id: messageText + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + text: messageDelegate.modelData.text + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + color: messageDelegate.isAssistantMessage ? Style.wizardPrimaryText : Style.wizardSelectedText + selectedTextColor: Style.wizardSelectedText + selectionColor: Style.ncBlue + textFormat: Text.MarkdownText + readOnly: true + selectByMouse: true + } + } + } + + onCountChanged: positionViewAtEnd() + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: root.currentUser !== null && root.currentUser.assistantResponse.length > 0 + text: visible ? root.currentUser.assistantResponse : "" + color: Style.wizardSecondaryText + font.pixelSize: Style.wizardBodyFontPixelSize + wrapMode: Text.WordWrap + } + + ErrorBox { + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: root.currentUser !== null && root.currentUser.assistantError.length > 0 + text: visible ? root.currentUser.assistantError : "" + } + } + + footer: [ + WizardTextField { + id: assistantQuestionInput + + Layout.fillWidth: true + Layout.preferredHeight: frame.footerButtonHeight + placeholderText: qsTr("Ask Assistant\u00A0…") + enabled: root.currentUser !== null + && root.currentUser.isAssistantEnabled + && root.currentUser.isConnected + && !root.currentUser.assistantRequestInProgress + onAccepted: root.submitQuestion() + }, + + WizardButton { + text: qsTr("New conversation") + enabled: root.hasAssistantConversation + onClicked: resetConfirmationDialog.open() + }, + + WizardButton { + primary: true + text: qsTr("Send") + enabled: assistantQuestionInput.enabled && assistantQuestionInput.text.trim().length > 0 + onClicked: root.submitQuestion() + } + ] + } +} diff --git a/src/gui/UserStatusWindow.qml b/src/gui/UserStatusWindow.qml index 6c0819e647a88..8c95ae46a2ae3 100644 --- a/src/gui/UserStatusWindow.qml +++ b/src/gui/UserStatusWindow.qml @@ -4,8 +4,7 @@ */ import QtQuick -import QtQuick.Controls -import QtQuick.Controls.Basic as BasicControls +import QtQuick.Controls.Basic import QtQuick.Layouts import com.nextcloud.desktopclient as NC @@ -174,7 +173,7 @@ ApplicationWindow { Layout.fillWidth: true spacing: 8 - BasicControls.Button { + Button { id: emojiButton readonly property string fallbackEmoji: "😀" @@ -198,7 +197,8 @@ ApplicationWindow { } background: Rectangle { - color: emojiButton.hovered ? Style.wizardRowBackground : "transparent" + visible: emojiButton.hovered || emojiButton.activeFocus + color: Style.wizardRowBackground } } @@ -214,7 +214,7 @@ ApplicationWindow { } } - BasicControls.Popup { + Popup { id: emojiPopup width: 420 @@ -292,7 +292,7 @@ ApplicationWindow { wrapMode: Text.Wrap } - BasicControls.ComboBox { + ComboBox { id: clearAtComboBox Layout.fillWidth: true diff --git a/src/gui/WindowAccountHeader.qml b/src/gui/WindowAccountHeader.qml new file mode 100644 index 0000000000000..c9b691fa63988 --- /dev/null +++ b/src/gui/WindowAccountHeader.qml @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts + +import Style +import "./tray" + +Item { + id: root + + property string title: "" + property var user: null + + readonly property string avatarSource: user && user.avatar !== "" + ? user.avatar + : (Style.darkMode ? "image://avatars/fallbackWhite" : "image://avatars/fallbackBlack") + readonly property int maximumAccountTextWidth: Math.max(0, + Math.round(width * 0.55) + - Style.wizardHeaderAvatarSize + - Style.wizardHeaderRowSpacing) + + implicitHeight: Math.max(titleLabel.implicitHeight, accountRow.implicitHeight) + + EnforcedPlainTextLabel { + id: titleLabel + + anchors.left: parent.left + anchors.right: accountRow.visible ? accountRow.left : parent.right + anchors.rightMargin: accountRow.visible ? Style.wizardHeaderSpacing : 0 + anchors.verticalCenter: parent.verticalCenter + text: root.title + color: Style.wizardPrimaryText + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + + RowLayout { + id: accountRow + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: implicitWidth + height: implicitHeight + visible: root.user !== null + spacing: Style.wizardHeaderRowSpacing + + Image { + Layout.preferredWidth: Style.wizardHeaderAvatarSize + Layout.preferredHeight: Style.wizardHeaderAvatarSize + source: root.avatarSource + sourceSize.width: Style.wizardHeaderAvatarSize + sourceSize.height: Style.wizardHeaderAvatarSize + fillMode: Image.PreserveAspectFit + cache: false + + Accessible.role: Accessible.Graphic + Accessible.name: qsTr("Account avatar") + } + + ColumnLayout { + Layout.preferredWidth: Math.min(root.maximumAccountTextWidth, + Math.max(accountNameLabel.implicitWidth, + accountServerLabel.implicitWidth)) + Layout.maximumWidth: root.maximumAccountTextWidth + Layout.minimumWidth: 0 + spacing: Style.wizardHeaderLabelSpacing + + EnforcedPlainTextLabel { + id: accountNameLabel + + Layout.fillWidth: true + text: root.user ? root.user.name : "" + color: Style.wizardPrimaryText + font.pixelSize: Style.wizardHeaderAccountNameFontPixelSize + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + + EnforcedPlainTextLabel { + id: accountServerLabel + + Layout.fillWidth: true + text: root.user ? root.user.server : "" + color: Style.wizardSecondaryText + font.pixelSize: Style.wizardHeaderAccountServerFontPixelSize + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + } + } +} diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index b7f865c244dc5..5b5c0610bc65c 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -4,11 +4,17 @@ */ #include "systray.h" +#include "accountmanager.h" +#include "iconjob.h" #include "tray/trayaccountappsmodel.h" #include "tray/usermodel.h" +#include #include #include +#include +#include +#include #import @@ -19,6 +25,8 @@ static const CGFloat kAvatarSize = 34.0; static const CGFloat kTopPadding = 4.0; static const CGFloat kActionHeight = 26.0; +static const CGFloat kPreviewActionHeight = 52.0; +static const CGFloat kDetailedPreviewActionHeight = 58.0; static const CGFloat kActionVerticalPadding = 8.0; static const CGFloat kCornerRadius = 14.0; static const CGFloat kHPad = 14.0; @@ -28,8 +36,12 @@ static const CGFloat kHoverMargin = 5.0; static const CGFloat kHoverRadius = 5.0; static const CGFloat kAccountHoverVerticalMargin = 4.0; -static const CGFloat kAccountActionsPopupWidth = 190.0; +static const CGFloat kCompactSeparatorVerticalMargin = 2.0; +static const CGFloat kAccountActionsPopupWidth = 340.0; static const CGFloat kAppsPopupWidth = 220.0; +static const CGFloat kNotificationActionsPopupWidth = 160.0; +static const CGFloat kSectionHeaderHeight = 24.0; +static const CGFloat kActivityPreviewIconSize = 16.0; typedef void (^NCActionHoverBlock)(NSView *row); @@ -64,6 +76,38 @@ return img; } +static QImage qImageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + if (imageData.isEmpty()) return {}; + + const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); + if (mimetype.isValid() && mimetype.inherits(QStringLiteral("image/svg+xml"))) { + QSvgRenderer renderer; + if (!renderer.load(imageData)) return {}; + + auto image = QImage(requestedSize, QImage::Format_ARGB32); + image.fill(Qt::transparent); + QPainter painter(&image); + const auto scaledSize = renderer.defaultSize().scaled(requestedSize, Qt::KeepAspectRatio); + const auto targetRect = QRectF(QPointF((requestedSize.width() - scaledSize.width()) / 2.0, + (requestedSize.height() - scaledSize.height()) / 2.0), + scaledSize); + renderer.render(&painter, targetRect); + return image; + } + + auto image = QImage::fromData(imageData); + if (!image.isNull() && requestedSize.isValid()) { + image = image.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return image; +} + +static NSImage *nsImageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + return nsImageFromQImage(qImageFromImageData(imageData, requestedSize)); +} + static QImage qImageFromQUrl(const QUrl &url) { if (url.isEmpty()) return {}; @@ -84,6 +128,15 @@ static QImage qImageFromQUrl(const QUrl &url) return nsImageFromQImage(qImageFromQUrl(url)); } +static NSImage *systemSymbolImage(const QString &symbolName, const CGFloat pointSize) +{ + auto image = [NSImage imageWithSystemSymbolName:symbolName.toNSString() accessibilityDescription:nil]; + if (!image) { + image = [NSImage imageWithSystemSymbolName:@"doc" accessibilityDescription:nil]; + } + return [image imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:pointSize weight:NSFontWeightRegular]]; +} + static QString statusText(OCC::UserStatus::OnlineStatus status) { switch (status) { @@ -113,14 +166,14 @@ static QString mainWindowText(const char *sourceText) return QCoreApplication::translate("MainWindow", sourceText); } -static QString fileDetailsPageText(const char *sourceText) +static QString trayWindowHeaderText(const char *sourceText) { - return QCoreApplication::translate("FileDetailsPage", sourceText); + return QCoreApplication::translate("TrayWindowHeader", sourceText); } -static QString trayWindowHeaderText(const char *sourceText) +static QString trayAccountPopupText(const char *sourceText) { - return QCoreApplication::translate("TrayWindowHeader", sourceText); + return QCoreApplication::translate("TrayAccountPopup", sourceText); } static QString statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) @@ -254,6 +307,38 @@ - (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width enabled:(BOOL)enabled action:(dispatch_block_t)action; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + subtitle:(NSString *)subtitle + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction; +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + subtitle:(NSString *)subtitle + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction; - (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width @@ -268,14 +353,18 @@ - (instancetype)initWithTitle:(NSString *)title hoverAction:(NCActionHoverBlock)hoverAction showsSubmenuIndicator:(BOOL)showsSubmenuIndicator; - (void)setPersistentHighlight:(BOOL)persistentHighlight; +- (void)setIcon:(NSImage *)icon; +- (void)setIconTintedToLabelColor:(BOOL)tinted; @end @implementation NCActionRow { dispatch_block_t _action; NCActionHoverBlock _hoverAction; NSView *_hoverView; + NSImageView *_iconView; NSTextField *_label; BOOL _actionEnabled; + BOOL _iconTintedToLabelColor; BOOL _mouseInside; BOOL _persistentHighlight; } @@ -328,6 +417,86 @@ - (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction showsSubmenuIndicator:(BOOL)showsSubmenuIndicator +{ + return [self initWithTitle:title + icon:icon + subtitle:nil + dateTime:nil + width:width + enabled:enabled + action:action + hoverAction:hoverAction + showsSubmenuIndicator:showsSubmenuIndicator]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction +{ + return [self initWithTitle:title + icon:icon + subtitle:nil + dateTime:dateTime + width:width + enabled:enabled + action:action + hoverAction:hoverAction + showsSubmenuIndicator:NO]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator +{ + return [self initWithTitle:title + icon:icon + subtitle:nil + dateTime:dateTime + width:width + enabled:enabled + action:action + hoverAction:hoverAction + showsSubmenuIndicator:showsSubmenuIndicator]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + subtitle:(NSString *)subtitle + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction +{ + return [self initWithTitle:title + icon:icon + subtitle:subtitle + dateTime:dateTime + width:width + enabled:enabled + action:action + hoverAction:hoverAction + showsSubmenuIndicator:NO]; +} + +- (instancetype)initWithTitle:(NSString *)title + icon:(NSImage *)icon + subtitle:(NSString *)subtitle + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator { self = [super init]; if (!self) return nil; @@ -344,17 +513,76 @@ - (instancetype)initWithTitle:(NSString *)title [self addSubview:_hoverView]; _label = [NSTextField labelWithString:title]; - _label.font = [NSFont systemFontOfSize:13]; + const auto isPreviewRow = dateTime.length > 0; + const auto hasSubtitle = subtitle.length > 0; + _label.font = isPreviewRow ? [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold] : [NSFont systemFontOfSize:13]; _label.textColor = enabled ? NSColor.labelColor : NSColor.tertiaryLabelColor; + _label.lineBreakMode = isPreviewRow && !hasSubtitle ? NSLineBreakByWordWrapping : NSLineBreakByTruncatingTail; + _label.maximumNumberOfLines = isPreviewRow && !hasSubtitle ? 2 : 1; _label.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:_label]; + + NSView *textContainer = nil; + NSTextField *subtitleLabel = nil; + NSTextField *dateTimeLabel = nil; + if (isPreviewRow) { + textContainer = [[NSView alloc] init]; + textContainer.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:textContainer]; + [textContainer addSubview:_label]; + + if (hasSubtitle) { + subtitleLabel = [NSTextField labelWithString:subtitle]; + subtitleLabel.font = [NSFont systemFontOfSize:13]; + subtitleLabel.textColor = enabled ? NSColor.secondaryLabelColor : NSColor.tertiaryLabelColor; + subtitleLabel.lineBreakMode = NSLineBreakByTruncatingTail; + subtitleLabel.maximumNumberOfLines = 1; + subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [textContainer addSubview:subtitleLabel]; + } + + dateTimeLabel = [NSTextField labelWithString:dateTime]; + dateTimeLabel.font = [NSFont systemFontOfSize:11]; + dateTimeLabel.textColor = enabled ? NSColor.secondaryLabelColor : NSColor.tertiaryLabelColor; + dateTimeLabel.alignment = NSTextAlignmentLeft; + dateTimeLabel.lineBreakMode = NSLineBreakByTruncatingTail; + dateTimeLabel.maximumNumberOfLines = 1; + dateTimeLabel.translatesAutoresizingMaskIntoConstraints = NO; + [textContainer addSubview:dateTimeLabel]; + } else { + [self addSubview:_label]; + } auto constraints = [NSMutableArray arrayWithArray:@[ - [_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], - [self.heightAnchor constraintEqualToConstant:kActionHeight], + [self.heightAnchor constraintEqualToConstant:isPreviewRow ? (hasSubtitle ? kDetailedPreviewActionHeight : kPreviewActionHeight) : kActionHeight], [self.widthAnchor constraintEqualToConstant:width], ]]; + if (isPreviewRow) { + [constraints addObjectsFromArray:@[ + [textContainer.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [textContainer.topAnchor constraintGreaterThanOrEqualToAnchor:self.topAnchor constant:4.0], + [textContainer.bottomAnchor constraintLessThanOrEqualToAnchor:self.bottomAnchor constant:-4.0], + [_label.topAnchor constraintEqualToAnchor:textContainer.topAnchor], + [_label.leadingAnchor constraintEqualToAnchor:textContainer.leadingAnchor], + [_label.trailingAnchor constraintEqualToAnchor:textContainer.trailingAnchor], + [dateTimeLabel.leadingAnchor constraintEqualToAnchor:textContainer.leadingAnchor], + [dateTimeLabel.trailingAnchor constraintEqualToAnchor:textContainer.trailingAnchor], + [dateTimeLabel.bottomAnchor constraintEqualToAnchor:textContainer.bottomAnchor], + ]]; + if (hasSubtitle) { + [constraints addObjectsFromArray:@[ + [subtitleLabel.topAnchor constraintEqualToAnchor:_label.bottomAnchor], + [subtitleLabel.leadingAnchor constraintEqualToAnchor:textContainer.leadingAnchor], + [subtitleLabel.trailingAnchor constraintEqualToAnchor:textContainer.trailingAnchor], + [dateTimeLabel.topAnchor constraintEqualToAnchor:subtitleLabel.bottomAnchor], + ]]; + } else { + [constraints addObject:[dateTimeLabel.topAnchor constraintEqualToAnchor:_label.bottomAnchor]]; + } + } else { + [constraints addObject:[_label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor]]; + } + if (showsSubmenuIndicator) { NSImageView *chevron = [[NSImageView alloc] init]; chevron.image = [[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:nil] @@ -367,33 +595,73 @@ - (instancetype)initWithTitle:(NSString *)title [chevron.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], [chevron.widthAnchor constraintEqualToConstant:8], [chevron.heightAnchor constraintEqualToConstant:13], - [_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8], ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } } else { - [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } } if (icon) { - NSImageView *iconView = [[NSImageView alloc] init]; - iconView.image = icon; - iconView.imageScaling = NSImageScaleProportionallyUpOrDown; - iconView.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:iconView]; + _iconView = [[NSImageView alloc] init]; + _iconView.image = icon; + _iconView.imageScaling = NSImageScaleProportionallyUpOrDown; + _iconView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_iconView]; [constraints addObjectsFromArray:@[ - [iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], - [iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], - [iconView.widthAnchor constraintEqualToConstant:18.0], - [iconView.heightAnchor constraintEqualToConstant:18.0], - [_label.leadingAnchor constraintEqualToAnchor:iconView.trailingAnchor constant:8.0], + [_iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [_iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [_iconView.widthAnchor constraintEqualToConstant:18.0], + [_iconView.heightAnchor constraintEqualToConstant:18.0], ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } } else { - [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } } [NSLayoutConstraint activateConstraints:constraints]; return self; } +- (void)setIcon:(NSImage *)icon +{ + if (icon && _iconView) { + if (_iconTintedToLabelColor) { + auto templateIcon = [icon copy]; + [templateIcon setTemplate:YES]; + _iconView.contentTintColor = NSColor.labelColor; + _iconView.image = templateIcon; + [templateIcon release]; + } else { + _iconView.contentTintColor = nil; + _iconView.image = icon; + } + } +} + +- (void)setIconTintedToLabelColor:(BOOL)tinted +{ + _iconTintedToLabelColor = tinted; + if (_iconView.image) { + [self setIcon:_iconView.image]; + } +} + - (void)updateHoverHighlight { _hoverView.hidden = !_actionEnabled || !(_mouseInside || _persistentHighlight); @@ -431,6 +699,231 @@ - (void)mouseUp:(NSEvent *)event @end +@interface NCSectionHeaderRow : NSView +- (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width; +@end + +@implementation NCSectionHeaderRow + +- (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width +{ + self = [super init]; + if (!self) return nil; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; + label.textColor = NSColor.secondaryLabelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [self.heightAnchor constraintEqualToConstant:kSectionHeaderHeight], + [self.widthAnchor constraintEqualToConstant:width], + [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad], + [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + ]]; + return self; +} + +@end + +@interface NCStaticInfoRow : NSView +- (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width; +@end + +@implementation NCStaticInfoRow + +- (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width +{ + self = [super init]; + if (!self) return nil; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:13]; + label.textColor = NSColor.labelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + auto constraints = [NSMutableArray arrayWithArray:@[ + [self.heightAnchor constraintEqualToConstant:kActionHeight], + [self.widthAnchor constraintEqualToConstant:width], + [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad], + ]]; + + if (icon) { + auto iconView = [[NSImageView alloc] init]; + iconView.image = icon; + iconView.contentTintColor = NSColor.secondaryLabelColor; + iconView.imageScaling = NSImageScaleProportionallyUpOrDown; + iconView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:iconView]; + [constraints addObjectsFromArray:@[ + [iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [iconView.widthAnchor constraintEqualToConstant:kActivityPreviewIconSize], + [iconView.heightAnchor constraintEqualToConstant:kActivityPreviewIconSize], + [label.leadingAnchor constraintEqualToAnchor:iconView.trailingAnchor constant:8.0], + ]]; + } else { + [constraints addObject:[label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } + + [NSLayoutConstraint activateConstraints:constraints]; + return self; +} + +@end + +@interface NCPointingHandButton : NSButton +@end + +@implementation NCPointingHandButton + +- (void)resetCursorRects +{ + [super resetCursorRects]; + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; +} + +@end + +@interface NCAlertBoxRow : NSView +- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction; +@end + +@implementation NCAlertBoxRow { + dispatch_block_t _action; + NCActionHoverBlock _hoverAction; +} + +- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction +{ + self = [super init]; + if (!self) return nil; + + _action = [action copy]; + _hoverAction = [hoverAction copy]; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; + label.textColor = NSColor.labelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.maximumNumberOfLines = 2; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + auto resolveButton = [[[NCPointingHandButton alloc] init] autorelease]; + resolveButton.title = trayAccountPopupText("Resolve").toNSString(); + resolveButton.target = self; + resolveButton.action = @selector(resolveButtonClicked:); + resolveButton.bezelStyle = NSBezelStyleRounded; + resolveButton.controlSize = NSControlSizeSmall; + resolveButton.font = [NSFont systemFontOfSize:11 weight:NSFontWeightRegular]; + resolveButton.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:resolveButton]; + + [NSLayoutConstraint activateConstraints:@[ + [self.widthAnchor constraintEqualToConstant:kPopupWidth], + [self.heightAnchor constraintGreaterThanOrEqualToConstant:kActionHeight], + + [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad + kAvatarSize + 10.0], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:resolveButton.leadingAnchor constant:-8.0], + [label.topAnchor constraintEqualToAnchor:self.topAnchor constant:kAccountHoverVerticalMargin], + [label.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-kAccountHoverVerticalMargin], + + [resolveButton.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-kHPad], + [resolveButton.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [resolveButton.widthAnchor constraintGreaterThanOrEqualToConstant:76.0], + ]]; + + return self; +} + +- (void)dealloc +{ + [_action release]; + [_hoverAction release]; + [super dealloc]; +} + +- (void)updateTrackingAreas +{ + [super updateTrackingAreas]; + auto trackingAreas = [self.trackingAreas copy]; + for (NSTrackingArea *area in trackingAreas) { + [self removeTrackingArea:area]; + } + [trackingAreas release]; + + auto trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea]; + [trackingArea release]; +} + +- (void)mouseEntered:(NSEvent *)event +{ + if (_hoverAction) { + _hoverAction(self); + } +} + +- (void)mouseUp:(NSEvent *)event +{ + if (_action) { + _action(); + } +} + +- (void)resolveButtonClicked:(id)sender +{ + if (_action) { + _action(); + } +} + +@end + +static NSView *accountActionsSeparator(const CGFloat verticalMargin) +{ + auto separator = [[NSBox alloc] init]; + separator.boxType = NSBoxSeparator; + separator.translatesAutoresizingMaskIntoConstraints = NO; + + auto container = [[NSView alloc] init]; + container.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:separator]; + + [NSLayoutConstraint activateConstraints:@[ + [container.widthAnchor constraintEqualToConstant:kAccountActionsPopupWidth], + [container.heightAnchor constraintEqualToConstant:(2.0 * verticalMargin) + 1.0], + [separator.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [separator.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [separator.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], + [separator.heightAnchor constraintEqualToConstant:1.0], + ]]; + return container; +} + +static NSView *accountActionsSeparator() +{ + return accountActionsSeparator(kAccountHoverVerticalMargin); +} + +static NSView *compactAccountActionsSeparator() +{ + return accountActionsSeparator(kCompactSeparatorVerticalMargin); +} + @interface NCSpacerView : NSView - (instancetype)initWithHeight:(CGFloat)height; - (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width; @@ -466,6 +959,7 @@ - (void)closeAccountActionsPopup; - (void)clearActiveAccountRow; - (void)openActivitiesForIndex:(int)index; - (void)openLocalFolderForIndex:(int)index; +- (void)openAssistantForIndex:(int)index; - (void)openOnlineStatusForIndex:(int)index; @end @@ -539,18 +1033,40 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner __unsafe_unretained NCTrayPopup *weakOwner = owner; auto appsModel = OCC::TrayAccountAppsModel::instance(); appsModel->setUserId(userIndex); + const auto accounts = OCC::AccountManager::instance()->accounts(); + const auto accountState = userIndex >= 0 && userIndex < accounts.size() + ? accounts.at(userIndex) + : OCC::AccountStatePtr{}; + auto fallbackIcon = [[NSImage imageWithSystemSymbolName:@"app" accessibilityDescription:nil] + imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightRegular]]; [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; for (auto row = 0; row < appsModel->rowCount(); ++row) { const auto appIndex = appsModel->index(row); const auto appUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::UrlRole).toUrl(); - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:appsModel->data(appIndex, OCC::TrayAccountAppsModel::NameRole).toString().toNSString() - icon:nsImageFromQUrl(appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl()) - width:kAppsPopupWidth - enabled:YES - action:^{ + const auto appIconUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl(); + auto appIcon = nsImageFromQUrl(appIconUrl); + auto actionRow = [[NCActionRow alloc] initWithTitle:appsModel->data(appIndex, OCC::TrayAccountAppsModel::NameRole).toString().toNSString() + icon:appIcon != nil ? appIcon : fallbackIcon + width:kAppsPopupWidth + enabled:YES + action:^{ [weakOwner closeAllPopups]; appsModel->openAppUrl(appUrl); - }]]; + }]; + [actionRow setIconTintedToLabelColor:YES]; + [_stack addArrangedSubview:actionRow]; + + if (!appIcon && accountState && accountState->account() && appIconUrl.isValid() && !appIconUrl.scheme().isEmpty()) { + auto retainedRow = [actionRow retain]; + auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); + QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow](const QByteArray &iconData) { + [retainedRow setIcon:nsImageFromImageData(iconData, QSize(18, 18))]; + [retainedRow release]; + }); + QObject::connect(iconJob, &OCC::IconJob::error, iconJob, [retainedRow](auto) { + [retainedRow release]; + }); + } } [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; @@ -564,17 +1080,137 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner @end +@interface NCNotificationActionsPopup : NSPanel +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner; +@end + +@implementation NCNotificationActionsPopup { + NSStackView *_stack; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kNotificationActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner +{ + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]]; + for (const auto &actionVariant : actions) { + const auto actionData = actionVariant.toMap(); + const auto title = actionData.value(QStringLiteral("label")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto actionType = actionData.value(QStringLiteral("actionType")).toString(); + const auto actionIndex = actionData.value(QStringLiteral("actionIndex")).toInt(); + const auto dismisses = actionType == QStringLiteral("dismiss"); + const auto opensActivities = actionType == QStringLiteral("openActivities"); + if (!dismisses && !opensActivities && actionIndex < 0) { + continue; + } + + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + width:kNotificationActionsPopupWidth + enabled:YES + action:^{ + if (dismisses) { + OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); + [weakOwner closeAccountActionsPopup]; + return; + } + if (opensActivities) { + [weakOwner openActivitiesForIndex:userIndex]; + return; + } + + [weakOwner closeAllPopups]; + OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); + }]]; + } + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]]; + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kNotificationActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + [self setFrame:frame display:NO]; + [self invalidateShadow]; +} + +@end + @interface NCAccountActionsPopup : NSPanel - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshActivities:(BOOL)refreshActivities; +- (BOOL)isShowingActivitiesForUserIndex:(int)userIndex; - (void)clearActiveSubmenuRow; - (void)hideAppsPopup; +- (void)showNotificationActionsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions; @end @implementation NCAccountActionsPopup { NSStackView *_stack; NCAppsPopup *_appsPopup; + NCNotificationActionsPopup *_notificationActionsPopup; NCActionRow *_activeSubmenuRow; __unsafe_unretained NCTrayPopup *_owner; + QMetaObject::Connection _recentActivitiesConnection; + int _userIndex; } - (instancetype)init @@ -590,6 +1226,7 @@ - (instancetype)init self.releasedWhenClosed = NO; self.backgroundColor = NSColor.clearColor; self.opaque = NO; + _userIndex = -1; NSView *container = [[NSView alloc] init]; container.wantsLayer = YES; @@ -629,10 +1266,21 @@ - (instancetype)init - (BOOL)canBecomeKeyWindow { return NO; } +- (BOOL)isShowingActivitiesForUserIndex:(int)userIndex +{ + return [self isVisible] && _userIndex == userIndex; +} + - (void)orderOut:(id)sender { [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; [self clearActiveSubmenuRow]; + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + _recentActivitiesConnection = {}; + } + _userIndex = -1; [super orderOut:sender]; } @@ -645,6 +1293,7 @@ - (void)clearActiveSubmenuRow - (void)hideAppsPopup { [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; [self clearActiveSubmenuRow]; } @@ -654,6 +1303,7 @@ - (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex _appsPopup = [[NCAppsPopup alloc] init]; } + [_notificationActionsPopup orderOut:nil]; [_appsPopup populateForUserIndex:userIndex owner:_owner]; [self clearActiveSubmenuRow]; if ([row isKindOfClass:[NCActionRow class]]) { @@ -691,9 +1341,59 @@ - (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex [_appsPopup orderFront:nil]; } +- (void)showNotificationActionsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions +{ + if (!_notificationActionsPopup) { + _notificationActionsPopup = [[NCNotificationActionsPopup alloc] init]; + } + + [_appsPopup orderOut:nil]; + [_notificationActionsPopup populateForUserIndex:userIndex activityIndex:activityIndex actions:actions owner:_owner]; + [self clearActiveSubmenuRow]; + if ([row isKindOfClass:[NCActionRow class]]) { + _activeSubmenuRow = (NCActionRow *)row; + [_activeSubmenuRow setPersistentHighlight:YES]; + } + + const auto rowTopLeftInWindow = [row convertPoint:NSMakePoint(NSMinX(row.bounds) + kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopRightInWindow = [row convertPoint:NSMakePoint(NSMaxX(row.bounds) - kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopLeftOnScreen = [row.window convertPointToScreen:rowTopLeftInWindow]; + auto rowTopRightOnScreen = [row.window convertPointToScreen:rowTopRightInWindow]; + + const auto popupWidth = _notificationActionsPopup.frame.size.width; + const auto popupHeight = _notificationActionsPopup.frame.size.height; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= popupHeight; + + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + const auto topEdge = NSMaxY(visibleFrame) - kScreenEdgePadding; + const auto bottomEdge = NSMinY(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + popupOrigin.y = popupOrigin.y < bottomEdge ? bottomEdge : (popupOrigin.y + popupHeight > topEdge ? topEdge - popupHeight : popupOrigin.y); + + [_notificationActionsPopup setFrameOrigin:popupOrigin]; + [_notificationActionsPopup orderFront:nil]; +} + - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + [self populateForUserIndex:userIndex owner:owner refreshActivities:YES]; +} + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshActivities:(BOOL)refreshActivities { _owner = owner; + _userIndex = userIndex; [_appsPopup orderOut:nil]; [self clearActiveSubmenuRow]; @@ -703,6 +1403,33 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner } auto model = OCC::UserModel::instance(); + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + _recentActivitiesConnection = {}; + } + if (!model || userIndex < 0 || userIndex >= model->rowCount()) { + return; + } + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCAccountActionsPopup *weakSelf = self; + _recentActivitiesConnection = QObject::connect(model, &QAbstractItemModel::dataChanged, model, + [weakSelf, weakOwner, userIndex](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { + if (!weakSelf || ![weakSelf isShowingActivitiesForUserIndex:userIndex]) { + return; + } + if (userIndex < topLeft.row() || userIndex > bottomRight.row()) { + return; + } + if (!roles.isEmpty() + && !roles.contains(OCC::UserModel::RecentActivitiesRole) + && !roles.contains(OCC::UserModel::TrayNotificationsRole)) { + return; + } + + [weakSelf populateForUserIndex:userIndex owner:weakOwner refreshActivities:NO]; + }); + const auto userModelIndex = model->index(userIndex); const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() && model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); @@ -710,9 +1437,13 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner const auto statusMessage = model->data(userModelIndex, OCC::UserModel::StatusMessageRole).toString(); NSImage *statusIcon = nsImageFromQUrl(model->data(userModelIndex, OCC::UserModel::StatusIconRole).toUrl()); - __unsafe_unretained NCTrayPopup *weakOwner = owner; - __unsafe_unretained NCAccountActionsPopup *weakSelf = self; + auto appsModel = OCC::TrayAccountAppsModel::instance(); + appsModel->setUserId(userIndex); + const auto appsEnabled = appsModel->rowCount() > 0; + const auto assistantEnabled = model->data(userModelIndex, OCC::UserModel::AssistantEnabledRole).toBool(); [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("User status").toNSString() + width:kAccountActionsPopupWidth]]; [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() icon:statusIcon width:kAccountActionsPopupWidth @@ -722,6 +1453,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner } hoverAction:^(NSView *) { [weakSelf hideAppsPopup]; }]]; + [_stack addArrangedSubview:accountActionsSeparator()]; [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayFoldersMenuButtonText("Open local folder").toNSString() width:kAccountActionsPopupWidth enabled:YES @@ -730,18 +1462,91 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner } hoverAction:^(NSView *) { [weakSelf hideAppsPopup]; }]]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:mainWindowText("Ask Assistant\302\240\342\200\246").toNSString() - width:kAccountActionsPopupWidth - enabled:NO - action:^{}]]; + if (assistantEnabled) { + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:mainWindowText("Ask Assistant\302\240\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openAssistantForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayWindowHeaderText("More apps").toNSString() + icon:nil + width:kAccountActionsPopupWidth + enabled:appsEnabled + action:^{} + hoverAction:^(NSView *row) { + [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; + } showsSubmenuIndicator:YES]]; - NSBox *separator = [[NSBox alloc] init]; - separator.boxType = NSBoxSeparator; - separator.translatesAutoresizingMaskIntoConstraints = NO; - [_stack addArrangedSubview:separator]; - [separator.widthAnchor constraintEqualToConstant:kAccountActionsPopupWidth].active = YES; + [_stack addArrangedSubview:accountActionsSeparator()]; + + const auto trayNotifications = model->data(userModelIndex, OCC::UserModel::TrayNotificationsRole).toList(); + if (!trayNotifications.isEmpty()) { + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("Notifications").toNSString() + width:kAccountActionsPopupWidth]]; + for (const auto &trayNotification : trayNotifications) { + const auto notificationData = trayNotification.toMap(); + const auto title = notificationData.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + continue; + } + const auto opensSettings = notificationData.value(QStringLiteral("opensSettings")).toBool(); + const auto notificationActions = notificationData.value(QStringLiteral("actions")).toList(); + const auto activityIndex = notificationData.value(QStringLiteral("activityIndex")).toInt(); + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + icon:systemSymbolImage(notificationData.value(QStringLiteral("systemIconName")).toString(), 14.0) + dateTime:notificationData.value(QStringLiteral("dateTime")).toString().toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + if (opensSettings) { + [weakOwner closeAllPopups]; + OCC::Systray::instance()->openSettings(); + } else { + [weakOwner openActivitiesForIndex:userIndex]; + } + } hoverAction:^(NSView *row) { + if (!notificationActions.isEmpty()) { + [weakSelf showNotificationActionsPopupFromRow:row forUserIndex:userIndex activityIndex:activityIndex actions:notificationActions]; + } else { + [weakSelf hideAppsPopup]; + } + } showsSubmenuIndicator:!notificationActions.isEmpty()]]; + } - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:fileDetailsPageText("Activity").toNSString() + [_stack addArrangedSubview:compactAccountActionsSeparator()]; + } + + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("Recent activity").toNSString() + width:kAccountActionsPopupWidth]]; + const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); + if (recentActivities.isEmpty()) { + [_stack addArrangedSubview:[[NCStaticInfoRow alloc] initWithTitle:trayAccountPopupText("No recent activity").toNSString() + icon:systemSymbolImage(QStringLiteral("clock"), 14.0) + width:kAccountActionsPopupWidth]]; + } + for (const auto &recentActivity : recentActivities) { + const auto activityData = recentActivity.toMap(); + const auto title = activityData.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + continue; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + icon:systemSymbolImage(activityData.value(QStringLiteral("systemIconName")).toString(), 14.0) + subtitle:activityData.value(QStringLiteral("subtitle")).toString().toNSString() + dateTime:activityData.value(QStringLiteral("dateTime")).toString().toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayAccountPopupText("More activity\342\200\246").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ @@ -749,17 +1554,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner } hoverAction:^(NSView *) { [weakSelf hideAppsPopup]; }]]; - auto appsModel = OCC::TrayAccountAppsModel::instance(); - appsModel->setUserId(userIndex); - const auto appsEnabled = appsModel->rowCount() > 0; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayWindowHeaderText("More apps").toNSString() - icon:nil - width:kAccountActionsPopupWidth - enabled:appsEnabled - action:^{} - hoverAction:^(NSView *row) { - [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; - } showsSubmenuIndicator:YES]]; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; [self.contentView layoutSubtreeIfNeeded]; @@ -768,6 +1563,10 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner frame.size.height = _stack.fittingSize.height; [self setFrame:frame display:NO]; [self invalidateShadow]; + + if (refreshActivities) { + model->fetchActivityPreview(userIndex); + } } @end @@ -955,11 +1754,21 @@ - (void)populate NSString *server = model->data(idx, OCC::UserModel::ServerRole).toString().toNSString(); NSImage *avatar = nsImageFromQImage(model->avatarForRow(i)); NSImage *syncStatus = nsImageFromQImage(model->syncStatusIconForRow(i)); + const auto accountAlert = model->data(idx, OCC::UserModel::AccountAlertRole).toMap(); [_stack addArrangedSubview:[self makeRowForIndex:i name:name server:server avatar:avatar syncStatusImage:syncStatus]]; + const auto accountAlertTitle = accountAlert.value(QStringLiteral("title")).toString(); + if (!accountAlertTitle.isEmpty()) { + [_stack addArrangedSubview:[[NCAlertBoxRow alloc] initWithTitle:accountAlertTitle.toNSString() + action:^{ + [self openActivitiesForIndex:i]; + } hoverAction:^(NSView *) { + [self closeAccountActionsPopup]; + }]]; + } } if (model->rowCount() > 0) { @@ -1061,8 +1870,7 @@ - (void)openActivitiesForIndex:(int)index [_accountActionsPopup orderOut:nil]; [self orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); - OCC::UserModel::instance()->setCurrentUserId(index); - OCC::Systray::instance()->showQMLWindow(); + OCC::Systray::instance()->showActivitiesWindow(index); } - (void)openLocalFolderForIndex:(int)index @@ -1088,6 +1896,13 @@ - (void)openLocalFolderForIndex:(int)index #endif } +- (void)openAssistantForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->showAssistantWindow(index); +} + - (void)openOnlineStatusForIndex:(int)index { [_accountActionsPopup orderOut:nil]; diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 89f0de6856217..ca5bc7a6b9df4 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -23,7 +23,9 @@ #endif #include +#include #include +#include #include #include #include @@ -46,6 +48,59 @@ namespace OCC { Q_LOGGING_CATEGORY(lcSystray, "nextcloud.gui.systray") +namespace { +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) +constexpr auto macOSWindowDragHandleHeight = 28; + +class QuickWindowDragHandle : public QObject +{ +public: + explicit QuickWindowDragHandle(QQuickWindow *window) + : QObject(window) + , _window(window) + { + } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (!_window || event->type() != QEvent::MouseButtonPress) { + return QObject::eventFilter(watched, event); + } + + const auto *mouseEvent = static_cast(event); + if (mouseEvent->button() != Qt::LeftButton) { + return QObject::eventFilter(watched, event); + } + + const auto windowPosition = _window->mapFromGlobal(mouseEvent->globalPosition().toPoint()); + if (windowPosition.y() < 0 || windowPosition.y() > macOSWindowDragHandleHeight) { + return QObject::eventFilter(watched, event); + } + + if (_window->startSystemMove()) { + event->accept(); + return true; + } + + return QObject::eventFilter(watched, event); + } + +private: + QPointer _window; +}; + +void configureMacOSExpandedQuickWindow(QQuickWindow *window) +{ + window->setFlag(Qt::ExpandedClientAreaHint, true); + window->setFlag(Qt::NoTitleBarBackgroundHint, true); + + auto *dragHandle = new QuickWindowDragHandle(window); + window->installEventFilter(dragHandle); +} +#endif +} + Systray *Systray::_instance = nullptr; Systray *Systray::instance() @@ -245,6 +300,159 @@ void Systray::showQMLWindow() UserModel::instance()->fetchCurrentActivityModel(); } +void Systray::showActivitiesWindow(int userIndex) +{ + const auto userModel = UserModel::instance(); + if (!userModel) { + return; + } + + const auto targetUserId = userIndex >= 0 ? userIndex : userModel->currentUserId(); + const auto user = userModel->user(targetUserId); + if (!user) { + qCWarning(lcSystray) << "Invalid user index for activities window:" << targetUserId; + return; + } + + hideWindow(); + + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open activities window as no tray engine was available"; + return; + } + + const auto windowKey = user->account()->id(); + + if (const auto existingWindow = _activitiesWindows.value(windowKey)) { + positionWindowAtScreenCenter(existingWindow.data()); + existingWindow->show(); + existingWindow->raise(); + existingWindow->requestActivate(); + userModel->fetchActivityModel(targetUserId); + return; + } + + const QVariantMap initialProperties{ + {"userIndex", targetUserId}, + {"currentUser", QVariant::fromValue(user)}, + {"activityModel", QVariant::fromValue(user->getActivityModel())}, + }; + QQmlComponent activitiesWindowComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/ActivitiesWindow.qml")); + + if (activitiesWindowComponent.isError()) { + qCWarning(lcSystray) << activitiesWindowComponent.errorString(); + qCWarning(lcSystray) << activitiesWindowComponent.errors(); + return; + } + + const auto createdObject = activitiesWindowComponent.createWithInitialProperties(initialProperties); + const auto window = qobject_cast(createdObject); + if (!window) { + qCWarning(lcSystray) << "Activities window resulted in creation of object that was not a window!"; + if (createdObject) { + createdObject->deleteLater(); + } + return; + } + + _activitiesWindows.insert(windowKey, window); + window->setIcon(Theme::instance()->applicationIcon()); + +#ifdef Q_OS_MACOS + auto *fgbg = new ForegroundBackground(this); + window->installEventFilter(fgbg); +#endif + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + configureMacOSExpandedQuickWindow(window); +#endif + + connect(window, &QObject::destroyed, this, [this, windowKey] { + _activitiesWindows.remove(windowKey); + }); + + positionWindowAtScreenCenter(window); + window->show(); + window->raise(); + window->requestActivate(); + userModel->fetchActivityModel(targetUserId); +} + +void Systray::showAssistantWindow(int userIndex) +{ + const auto userModel = UserModel::instance(); + if (!userModel) { + return; + } + + const auto targetUserId = userIndex >= 0 ? userIndex : userModel->currentUserId(); + const auto user = userModel->user(targetUserId); + if (!user || !user->isNcAssistantEnabled()) { + qCWarning(lcSystray) << "Not opening assistant window for account without assistant support:" << targetUserId; + return; + } + + hideWindow(); + + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open assistant window as no tray engine was available"; + return; + } + + const auto windowKey = user->account()->id(); + + if (const auto existingWindow = _assistantWindows.value(windowKey)) { + positionWindowAtScreenCenter(existingWindow.data()); + existingWindow->show(); + existingWindow->raise(); + existingWindow->requestActivate(); + return; + } + + const QVariantMap initialProperties{ + {"userIndex", targetUserId}, + {"currentUser", QVariant::fromValue(user)}, + }; + QQmlComponent assistantWindowComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/AssistantWindow.qml")); + + if (assistantWindowComponent.isError()) { + qCWarning(lcSystray) << assistantWindowComponent.errorString(); + qCWarning(lcSystray) << assistantWindowComponent.errors(); + return; + } + + const auto createdObject = assistantWindowComponent.createWithInitialProperties(initialProperties); + const auto window = qobject_cast(createdObject); + if (!window) { + qCWarning(lcSystray) << "Assistant window resulted in creation of object that was not a window!"; + if (createdObject) { + createdObject->deleteLater(); + } + return; + } + + _assistantWindows.insert(windowKey, window); + window->setIcon(Theme::instance()->applicationIcon()); + +#ifdef Q_OS_MACOS + auto *fgbg = new ForegroundBackground(this); + window->installEventFilter(fgbg); +#endif + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + configureMacOSExpandedQuickWindow(window); +#endif + + connect(window, &QObject::destroyed, this, [this, windowKey] { + _assistantWindows.remove(windowKey); + }); + + positionWindowAtScreenCenter(window); + window->show(); + window->raise(); + window->requestActivate(); +} + void Systray::showUserStatusWindow(int userIndex) { const auto userModel = UserModel::instance(); @@ -306,8 +514,7 @@ void Systray::showUserStatusWindow(int userIndex) #endif #if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) - _userStatusWindow->setFlag(Qt::ExpandedClientAreaHint, true); - _userStatusWindow->setFlag(Qt::NoTitleBarBackgroundHint, true); + configureMacOSExpandedQuickWindow(_userStatusWindow.data()); #endif connect(_userStatusWindow.data(), &QObject::destroyed, this, [this] { @@ -796,6 +1003,21 @@ bool Systray::isOpen() const return _isOpen; } +bool Systray::isActivitySurfaceVisible() const +{ + if (isOpen()) { + return true; + } + + for (auto it = _activitiesWindows.cbegin(); it != _activitiesWindows.cend(); ++it) { + if (it.value() && it.value()->isVisible()) { + return true; + } + } + + return false; +} + bool Systray::enableAddAccount() const { #if defined ENFORCE_SINGLE_ACCOUNT diff --git a/src/gui/systray.h b/src/gui/systray.h index 629b0fdd4d499..ffcb4fd44fc9e 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -12,6 +12,7 @@ #include #include +#include #include class QScreen; @@ -90,6 +91,7 @@ class Systray : public QSystemTrayIcon [[nodiscard]] bool syncIsPaused() const; [[nodiscard]] bool anySyncFolders() const; [[nodiscard]] bool isOpen() const; + [[nodiscard]] bool isActivitySurfaceVisible() const; [[nodiscard]] bool enableAddAccount() const; @@ -147,6 +149,8 @@ public slots: void showWindow(OCC::Systray::WindowPosition position = OCC::Systray::WindowPosition::Default); void hideWindow(); void showQMLWindow(); + void showActivitiesWindow(int userIndex = -1); + void showAssistantWindow(int userIndex = -1); void showUserStatusWindow(int userIndex); void setSyncIsPaused(const bool syncIsPaused); @@ -213,6 +217,8 @@ private slots: std::unique_ptr _trayEngine; QPointer _contextMenu; QSharedPointer _trayWindow; + QHash> _activitiesWindows; + QHash> _assistantWindows; QPointer _userStatusWindow; #ifndef Q_OS_MACOS QSharedPointer _popupWindow; diff --git a/src/gui/tray/ActivityItemActions.qml b/src/gui/tray/ActivityItemActions.qml index 3997f9250f6e8..6c5ea3df00efb 100644 --- a/src/gui/tray/ActivityItemActions.qml +++ b/src/gui/tray/ActivityItemActions.qml @@ -47,7 +47,7 @@ Repeater { icon.source: model.modelData.imageSource ? model.modelData.imageSource + Style.adjustedCurrentUserHeaderColor : "" - onClicked: isTalkReplyButton ? root.showReplyField() : root.triggerAction(model.index) + onClicked: isTalkReplyButton ? root.showReplyField() : root.triggerAction(model.modelData.actionIndex) visible: verb !== "REPLY" || (verb === "REPLY" && root.talkReplyButtonVisible) } diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index e0c70e93c7a23..a0805cf55fc06 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -262,10 +262,7 @@ ApplicationWindow { onFeaturedAppButtonClicked: { if (UserModel.currentUser.isAssistantEnabled) { - trayWindowMainItem.showAssistantPanel = !trayWindowMainItem.showAssistantPanel - if (trayWindowMainItem.showAssistantPanel) { - assistantQuestionInput.forceActiveFocus() - } + Systray.showAssistantWindow(UserModel.currentUserId) } else { UserModel.openCurrentAccountFeaturedApp() } @@ -800,6 +797,8 @@ ApplicationWindow { id: syncStatus accentColor: Style.accentColor + user: UserModel.currentUser + activityListModel: activityModel visible: !trayWindowMainItem.isUnifiedSearchActive && !trayWindowMainItem.showAssistantPanel anchors.top: trayWindowMainItem.showAssistantPanel ? assistantInputContainer.bottom : trayWindowUnifiedSearchInputContainer.bottom diff --git a/src/gui/tray/SyncStatus.qml b/src/gui/tray/SyncStatus.qml index 8827d8a842991..23d12457efde0 100644 --- a/src/gui/tray/SyncStatus.qml +++ b/src/gui/tray/SyncStatus.qml @@ -16,6 +16,8 @@ RowLayout { property alias model: syncStatus property color accentColor: Style.ncBlue + property var user: NC.UserModel.currentUser + property var activityListModel: null spacing: Style.trayHorizontalMargin @@ -100,16 +102,18 @@ RowLayout { padding: Style.smallSpacing - visible: !activityModel.hasSyncConflicts && + visible: root.user !== null && + root.activityListModel !== null && + !root.activityListModel.hasSyncConflicts && !syncStatus.syncing && !syncStatus.needsSandboxReapproval && - (NC.UserModel.currentUser.hasLocalFolder || - (Qt.platform.os === "osx" && NC.UserModel.currentUser.hasFileProvider)) && - NC.UserModel.currentUser.isConnected + (root.user.hasLocalFolder || + (Qt.platform.os === "osx" && root.user.hasFileProvider)) && + root.user.isConnected enabled: visible onClicked: { if(!syncStatus.syncing) { - NC.UserModel.currentUser.forceSyncNow(); + root.user.forceSyncNow(); } } } @@ -119,12 +123,14 @@ RowLayout { text: qsTr("Resolve conflicts") - visible: activityModel.hasSyncConflicts && + visible: root.user !== null && + root.activityListModel !== null && + root.activityListModel.hasSyncConflicts && !syncStatus.syncing && - NC.UserModel.currentUser.hasLocalFolder && - NC.UserModel.currentUser.isConnected + root.user.hasLocalFolder && + root.user.isConnected enabled: visible - onClicked: NC.Systray.createResolveConflictsDialog(activityModel.allConflicts); + onClicked: NC.Systray.createResolveConflictsDialog(root.activityListModel.allConflicts); } Button { @@ -132,10 +138,10 @@ RowLayout { text: qsTr("Open browser") - visible: NC.UserModel.currentUser.needsToSignTermsOfService + visible: root.user !== null && root.user.needsToSignTermsOfService enabled: visible - onClicked: NC.UserModel.openCurrentAccountServer() + onClicked: root.user.openServer() } Button { @@ -145,8 +151,9 @@ RowLayout { visible: syncStatus.needsSandboxReapproval && !syncStatus.syncing && - NC.UserModel.currentUser.hasLocalFolder && - NC.UserModel.currentUser.isConnected + root.user !== null && + root.user.hasLocalFolder && + root.user.isConnected enabled: visible onClicked: NC.Systray.openSettingsForSandboxReapproval() diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 5db9f45865231..c0598a8d750af 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -4,9 +4,8 @@ */ import QtQuick -import QtQuick.Controls +import QtQuick.Controls.Basic import QtQuick.Layouts -import QtQuick.Window import Qt5Compat.GraphicalEffects import Style @@ -18,6 +17,10 @@ import com.nextcloud.desktopclient as NC Window { id: root + property bool _closing: false + property bool _hadFocusSinceShow: false + property var activeAccountActionsMenu: null + readonly property bool hasAccounts: UserModel && UserModel.count > 0 readonly property color rowHoverColor: Style.darkMode ? Qt.rgba(1, 1, 1, Style.trayAccountPopupRowHoverOpacity) @@ -28,13 +31,11 @@ Window { color: "transparent" flags: Qt.Tool | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint - property bool _closing: false - property bool _hadFocusSinceShow: false - property var activeAccountActionsMenu: null - onVisibleChanged: { if (visible) { _hadFocusSinceShow = false + } else { + closeActiveTraySubmenus() } } @@ -54,6 +55,10 @@ Window { activeAccountActionsMenu = null } + function closeActiveTraySubmenus() { + closeActiveAccountActionsMenu() + } + function translatedAskAssistantText() { return qsTranslate("MainWindow", "Ask Assistant\u00A0…") } @@ -66,7 +71,7 @@ Window { border.width: Style.trayWindowBorderWidth border.color: palette.dark clip: true - layer.enabled: true + layer.enabled: root.visible layer.effect: OpacityMask { maskSource: Rectangle { width: popupContainer.width @@ -89,35 +94,32 @@ Window { Repeater { model: UserModel - delegate: ItemDelegate { - id: accountRow + delegate: Column { + id: accountDelegate readonly property int userId: model.id readonly property int onlineStatus: model.status readonly property bool onlineStatusEnabled: model.isConnected && model.serverHasUserStatus readonly property string statusIcon: model.statusIcon readonly property string statusMessage: model.statusMessage - readonly property bool menuHighlighted: hovered || accountActionsMenu.opened + readonly property var recentActivities: model.recentActivities ? model.recentActivities : [] + readonly property var trayNotifications: model.trayNotifications ? model.trayNotifications : [] + readonly property var accountAlert: model.accountAlert ? model.accountAlert : ({}) + readonly property string accountAlertTitle: accountAlert.title ? accountAlert.title : "" + readonly property bool hasAccountAlert: accountAlertTitle !== "" + readonly property bool assistantEnabled: model.assistantEnabled width: root.width - height: Style.trayAccountPopupRowHeight - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - rightPadding: Style.trayAccountPopupRowPadding + height: accountRow.height + accountAlertBox.height + spacing: 0 function openActivities() { root._closing = true - UserModel.currentUserId = accountRow.userId - Systray.showQMLWindow() + Systray.showActivitiesWindow(accountDelegate.userId) } function openLocalFolder() { root._closing = true - UserModel.currentUserId = accountRow.userId + UserModel.currentUserId = accountDelegate.userId Systray.hideWindow() if (UserModel.currentUser && UserModel.currentUser.hasLocalFolder) { UserModel.openCurrentAccountLocalFolder() @@ -128,6 +130,11 @@ Window { } } + function openAssistant() { + root._closing = true + Systray.showAssistantWindow(accountDelegate.userId) + } + function currentStatusText() { switch (onlineStatus) { case NC.userStatus.Away: @@ -147,27 +154,28 @@ Window { } function currentStatusLabelText() { - var message = statusMessage.trim() + const message = statusMessage.trim() return message !== "" ? message : currentStatusText() } function openAccountActionsMenu() { - TrayAccountAppsModel.setUserId(accountRow.userId) + TrayAccountAppsModel.setUserId(accountDelegate.userId) + UserModel.fetchActivityPreview(accountDelegate.userId) root.closeActiveAccountActionsMenu() - var rightAlignedX = Math.max(Style.trayAccountPopupHoverMargin, - accountRow.width - accountActionsMenu.width - Style.trayAccountPopupHoverMargin) - var leftAlignedX = Style.trayAccountPopupHoverMargin - var rowPosition = accountRow.mapToItem(popupContainer, 0, 0) - var screenLeft = root.screen && root.screen.virtualX !== undefined ? root.screen.virtualX : root.x - var screenWidth = root.screen && root.screen.width !== undefined ? root.screen.width : root.width - var screenRight = screenLeft + screenWidth - var rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + accountActionsMenu.width + const rightAlignedX = Math.max(Style.trayAccountPopupHoverMargin, + accountRow.width - accountActionsMenu.width - Style.trayAccountPopupHoverMargin) + const leftAlignedX = Style.trayAccountPopupHoverMargin + const rowPosition = accountRow.mapToItem(popupContainer, 0, 0) + const screenLeft = root.screen ? root.screen.virtualX : root.x + const screenWidth = root.screen ? root.screen.width : root.width + const screenRight = screenLeft + screenWidth + const rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + accountActionsMenu.width - var menuX = rightAlignedScreenRight > screenRight - Style.trayAccountPopupHoverMargin - && root.x + rowPosition.x + leftAlignedX >= screenLeft + Style.trayAccountPopupHoverMargin - ? leftAlignedX - : rightAlignedX + const menuX = rightAlignedScreenRight > screenRight - Style.trayAccountPopupHoverMargin + && root.x + rowPosition.x + leftAlignedX >= screenLeft + Style.trayAccountPopupHoverMargin + ? leftAlignedX + : rightAlignedX accountActionsMenu.popup(accountRow, menuX, @@ -175,6 +183,20 @@ Window { root.activeAccountActionsMenu = accountActionsMenu } + ItemDelegate { + id: accountRow + + width: root.width + height: Style.trayAccountPopupRowHeight + hoverEnabled: true + topInset: 0 + leftInset: 0 + rightInset: 0 + bottomInset: 0 + padding: 0 + leftPadding: Style.trayAccountPopupRowPadding + rightPadding: Style.trayAccountPopupRowPadding + onHoveredChanged: { if (hovered && !accountActionsMenu.opened) { openAccountActionsMenu() @@ -192,18 +214,24 @@ Window { anchors.topMargin: Style.trayAccountPopupAccountHoverVerticalMargin anchors.bottomMargin: Style.trayAccountPopupAccountHoverVerticalMargin radius: Style.trayAccountPopupHoverRadius - color: accountRow.menuHighlighted ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + visible: opacity > 0 + color: root.rowHoverColor + opacity: accountRow.hovered || accountActionsMenu.opened ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } } AutoSizingMenu { id: accountActionsMenu + property var activeNotificationActionsMenu: null + + width: Style.trayAccountActionsMenuWidth closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape height: implicitHeight onClosed: { appsMenu.close() + closeNotificationActionsMenu() if (root.activeAccountActionsMenu === accountActionsMenu) { root.activeAccountActionsMenu = null } @@ -215,28 +243,61 @@ Window { } } + function closeNotificationActionsMenu() { + if (activeNotificationActionsMenu && activeNotificationActionsMenu.opened) { + activeNotificationActionsMenu.close() + } + activeNotificationActionsMenu = null + } + + function closeSubmenus() { + closeAppsMenu() + closeNotificationActionsMenu() + } + + MenuItem { + id: userStatusHeader + + enabled: false + text: qsTr("User status") + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + font.weight: Font.DemiBold + hoverEnabled: false + background: Item {} + contentItem: EnforcedPlainTextLabel { + text: userStatusHeader.text + font: userStatusHeader.font + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + } + + Accessible.role: Accessible.StaticText + Accessible.name: text + } + MenuItem { id: statusButton - enabled: accountRow.onlineStatusEnabled - text: accountRow.currentStatusLabelText() + enabled: accountDelegate.onlineStatusEnabled + text: accountDelegate.currentStatusLabelText() font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: true onHoveredChanged: { if (hovered) { - accountActionsMenu.closeAppsMenu() + accountActionsMenu.closeSubmenus() } } contentItem: RowLayout { spacing: 8 Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize visible: statusButton.enabled - source: statusButton.enabled ? accountRow.statusIcon : "" - sourceSize.width: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset - sourceSize.height: Style.trayAccountPopupSyncIconSize + Style.trayFolderStatusIndicatorSizeOffset + source: statusButton.enabled ? accountDelegate.statusIcon : "" + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize cache: false } @@ -250,7 +311,7 @@ Window { } onClicked: { root._closing = true - Systray.showUserStatusWindow(accountRow.userId) + Systray.showUserStatusWindow(accountDelegate.userId) } Accessible.role: Accessible.Button @@ -258,6 +319,9 @@ Window { Accessible.onPressAction: statusButton.clicked() } + MenuSeparator { + } + MenuItem { id: openLocalFolderButton @@ -266,10 +330,10 @@ Window { hoverEnabled: true onHoveredChanged: { if (hovered) { - accountActionsMenu.closeAppsMenu() + accountActionsMenu.closeSubmenus() } } - onClicked: accountRow.openLocalFolder() + onClicked: accountDelegate.openLocalFolder() Accessible.role: Accessible.Button Accessible.name: text @@ -279,39 +343,22 @@ Window { MenuItem { id: assistantButton - enabled: false + visible: accountDelegate.assistantEnabled + enabled: accountDelegate.assistantEnabled + height: visible ? implicitHeight : 0 text: root.translatedAskAssistantText() font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: true onHoveredChanged: { if (hovered) { - accountActionsMenu.closeAppsMenu() - } - } - - Accessible.role: Accessible.Button - Accessible.name: text - } - - MenuSeparator { - } - - MenuItem { - id: activitiesButton - - text: qsTranslate("FileDetailsPage", "Activity") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeAppsMenu() + accountActionsMenu.closeSubmenus() } } - onClicked: accountRow.openActivities() + onClicked: accountDelegate.openAssistant() Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: activitiesButton.clicked() + Accessible.onPressAction: assistantButton.clicked() } MenuItem { @@ -326,7 +373,8 @@ Window { if (!enabled) { return } - TrayAccountAppsModel.setUserId(accountRow.userId) + accountActionsMenu.closeNotificationActionsMenu() + TrayAccountAppsModel.setUserId(accountDelegate.userId) if (!appsMenu.opened) { appsMenu.popup(appsButton, appsButton.width, 0) } @@ -341,7 +389,10 @@ Window { onClicked: openAppsMenu() background: Rectangle { - color: appsButton.hovered || appsMenu.opened ? root.rowHoverColor : "transparent" + visible: opacity > 0 + color: root.rowHoverColor + opacity: appsButton.hovered || appsMenu.opened ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } contentItem: RowLayout { @@ -379,10 +430,37 @@ Window { delegate: MenuItem { id: appEntry - text: " " + model.appName + text: model.appName font.pixelSize: Style.trayAccountPopupPrimaryFontSize - icon.source: "image://tray-image-provider/" + model.appIconUrl - icon.color: palette.windowText + + function appIconSource() { + if (!model.appIconUrl || model.appIconUrl === "") { + return "" + } + return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText + } + + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + source: appEntry.appIconSource() + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + fillMode: Image.PreserveAspectFit + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: appEntry.text + font: appEntry.font + color: palette.windowText + elide: Text.ElideRight + } + } + onTriggered: { root._closing = true appsMenu.close() @@ -397,6 +475,364 @@ Window { } } } + + MenuSeparator { + } + + MenuItem { + id: notificationsHeader + + visible: accountDelegate.trayNotifications.length > 0 + height: visible ? implicitHeight : 0 + enabled: false + text: qsTr("Notifications") + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + font.weight: Font.DemiBold + hoverEnabled: false + background: Item {} + contentItem: EnforcedPlainTextLabel { + text: notificationsHeader.text + font: notificationsHeader.font + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + } + + Accessible.role: Accessible.StaticText + Accessible.name: text + } + + Repeater { + model: accountDelegate.trayNotifications + + delegate: MenuItem { + id: notificationRow + + required property var modelData + + readonly property var notificationActions: modelData.actions ? modelData.actions : [] + readonly property bool hasNotificationActions: notificationActions.length > 0 + readonly property string rowDateTime: modelData.dateTime ? modelData.dateTime : "" + + text: modelData.title + height: Style.trayAccountPopupPreviewActionHeight + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + + function iconSource() { + if (!modelData.icon || modelData.icon === "") { + return "image://svgimage-custom-color/activity.svg/" + palette.windowText + } + return modelData.icon + "/" + palette.windowText + } + + function openNotification() { + accountActionsMenu.closeSubmenus() + if (modelData.opensSettings === true) { + root._closing = true + accountActionsMenu.close() + Systray.hideWindow() + Systray.openSettings() + return + } + accountDelegate.openActivities() + } + + function openNotificationActionsMenu() { + if (!hasNotificationActions) { + return + } + if (accountActionsMenu.activeNotificationActionsMenu + && accountActionsMenu.activeNotificationActionsMenu !== notificationActionsMenu) { + accountActionsMenu.activeNotificationActionsMenu.close() + } + if (!notificationActionsMenu.opened) { + notificationActionsMenu.popup(notificationRow, notificationRow.width, 0) + } + accountActionsMenu.activeNotificationActionsMenu = notificationActionsMenu + } + + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeAppsMenu() + if (hasNotificationActions) { + openNotificationActionsMenu() + } else { + accountActionsMenu.closeNotificationActionsMenu() + } + } + } + + onClicked: openNotification() + + background: Rectangle { + visible: opacity > 0 + color: root.rowHoverColor + opacity: notificationRow.hovered || notificationActionsMenu.opened ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + } + + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + Layout.alignment: Qt.AlignVCenter + source: notificationRow.iconSource() + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: notificationRow.text + font: notificationRow.font + color: palette.windowText + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: notificationRow.rowDateTime + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + color: palette.windowText + opacity: 0.65 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + visible: text !== "" + } + } + + EnforcedPlainTextLabel { + Layout.alignment: Qt.AlignVCenter + visible: notificationRow.hasNotificationActions + text: "›" + font.pixelSize: Style.trayAccountPopupChevronFontSize + color: palette.windowText + opacity: 0.35 + } + } + + AutoSizingMenu { + id: notificationActionsMenu + + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + onClosed: { + if (accountActionsMenu.activeNotificationActionsMenu === notificationActionsMenu) { + accountActionsMenu.activeNotificationActionsMenu = null + } + } + + Repeater { + model: notificationRow.notificationActions + + delegate: MenuItem { + id: notificationActionMenuItem + + required property var modelData + + text: modelData.label + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + onTriggered: { + const activityIndex = notificationRow.modelData.activityIndex + const actionIndex = modelData.actionIndex + notificationActionsMenu.close() + if (modelData.actionType === "dismiss") { + UserModel.dismissNotification(accountDelegate.userId, activityIndex) + return + } + if (modelData.actionType === "openActivities") { + accountDelegate.openActivities() + return + } + root._closing = true + accountActionsMenu.close() + Systray.hideWindow() + UserModel.triggerNotificationAction(accountDelegate.userId, activityIndex, actionIndex) + } + + Accessible.role: Accessible.MenuItem + Accessible.name: text + Accessible.onPressAction: notificationActionMenuItem.triggered() + } + } + } + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: notificationRow.clicked() + } + } + + MenuSeparator { + visible: accountDelegate.trayNotifications.length > 0 + height: visible ? Style.trayAccountPopupCompactSeparatorHeight : 0 + } + + MenuItem { + id: lastActivitiesHeader + + enabled: false + text: qsTr("Recent activity") + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + font.weight: Font.DemiBold + hoverEnabled: false + background: Item {} + contentItem: EnforcedPlainTextLabel { + text: lastActivitiesHeader.text + font: lastActivitiesHeader.font + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + } + + Accessible.role: Accessible.StaticText + Accessible.name: text + } + + Repeater { + model: accountDelegate.recentActivities + + delegate: MenuItem { + id: recentActivityRow + + required property var modelData + + readonly property string rowDateTime: modelData.dateTime ? modelData.dateTime : "" + readonly property string rowSubtitle: modelData.subtitle ? modelData.subtitle : "" + + enabled: true + text: modelData.title + height: rowSubtitle !== "" ? Style.trayAccountPopupDetailedPreviewActionHeight + : Style.trayAccountPopupPreviewActionHeight + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + background: Rectangle { + visible: opacity > 0 + color: root.rowHoverColor + opacity: recentActivityRow.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + } + + function iconSource() { + if (!modelData.icon || modelData.icon === "") { + return "image://svgimage-custom-color/activity.svg/" + palette.windowText + } + return modelData.icon + "/" + palette.windowText + } + + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + Layout.alignment: Qt.AlignVCenter + source: recentActivityRow.iconSource() + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: recentActivityRow.text + font: recentActivityRow.font + font.weight: Font.DemiBold + color: palette.windowText + elide: Text.ElideRight + maximumLineCount: 1 + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: recentActivityRow.rowSubtitle + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + maximumLineCount: 1 + visible: text !== "" + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: recentActivityRow.rowDateTime + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + color: palette.windowText + opacity: 0.65 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + visible: text !== "" + } + } + } + + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeSubmenus() + } + } + + onClicked: accountDelegate.openActivities() + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: recentActivityRow.clicked() + } + } + + MenuItem { + id: noRecentActivitiesRow + + visible: accountDelegate.recentActivities.length === 0 + height: visible ? implicitHeight : 0 + enabled: false + text: qsTr("No recent activity") + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: false + background: Item {} + contentItem: EnforcedPlainTextLabel { + text: noRecentActivitiesRow.text + font: noRecentActivitiesRow.font + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + } + + Accessible.role: Accessible.StaticText + Accessible.name: text + } + + MenuItem { + id: moreActivitiesButton + + text: qsTr("More activity…") + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + onHoveredChanged: { + if (hovered) { + accountActionsMenu.closeSubmenus() + } + } + onClicked: accountDelegate.openActivities() + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: moreActivitiesButton.clicked() + } } contentItem: RowLayout { @@ -409,7 +845,7 @@ Window { : (Style.darkMode ? "image://avatars/fallbackWhite" : "image://avatars/fallbackBlack") fillMode: Image.PreserveAspectCrop cache: false - layer.enabled: true + layer.enabled: visible && status === Image.Ready layer.effect: OpacityMask { maskSource: Rectangle { width: Style.trayAccountPopupAvatarSize @@ -460,9 +896,104 @@ Window { } onClicked: { - accountRow.openActivities() + accountDelegate.openActivities() } } + + ItemDelegate { + id: accountAlertBox + + visible: accountDelegate.hasAccountAlert + width: root.width + height: visible ? Math.max(Style.trayAccountPopupActionHeight, + accountAlertLabel.implicitHeight + + (2 * Style.trayAccountPopupAccountHoverVerticalMargin)) : 0 + hoverEnabled: true + topInset: 0 + leftInset: 0 + rightInset: 0 + bottomInset: 0 + padding: 0 + leftPadding: Style.trayAccountPopupRowPadding + rightPadding: Style.trayAccountPopupRowPadding + + background: Item {} + + contentItem: RowLayout { + spacing: Style.trayAccountPopupRowSpacing + + Item { + Layout.preferredWidth: Style.trayAccountPopupAvatarSize + Layout.fillHeight: true + } + + EnforcedPlainTextLabel { + id: accountAlertLabel + + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + text: accountDelegate.accountAlertTitle + font.pixelSize: Style.trayAccountPopupSecondaryFontSize + font.weight: Font.DemiBold + color: palette.windowText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + + Button { + id: accountAlertResolveButton + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Math.max(implicitWidth, 82) + Layout.preferredHeight: Style.trayAccountPopupActionHeight + text: qsTr("Resolve") + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + onClicked: accountDelegate.openActivities() + + background: Rectangle { + radius: Style.mediumRoundedButtonRadius + color: accountAlertResolveButton.hovered || accountAlertResolveMouseArea.containsMouse ? palette.mid : palette.button + border.color: palette.mid + border.width: Style.trayWindowBorderWidth + } + + contentItem: EnforcedPlainTextLabel { + text: accountAlertResolveButton.text + font: accountAlertResolveButton.font + color: palette.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + MouseArea { + id: accountAlertResolveMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: accountDelegate.openActivities() + } + + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: accountAlertResolveButton.clicked() + } + } + + onHoveredChanged: { + if (hovered) { + root.closeActiveAccountActionsMenu() + } + } + + onClicked: accountDelegate.openActivities() + + Accessible.role: Accessible.Button + Accessible.name: accountDelegate.accountAlertTitle + Accessible.onPressAction: accountAlertBox.clicked() + } } Rectangle { @@ -500,8 +1031,10 @@ Window { anchors.leftMargin: Style.trayAccountPopupHoverMargin anchors.rightMargin: Style.trayAccountPopupHoverMargin radius: Style.trayAccountPopupHoverRadius - color: addAccountRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + visible: opacity > 0 + color: root.rowHoverColor + opacity: addAccountRow.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } } @@ -514,7 +1047,7 @@ Window { onHoveredChanged: { if (hovered) { - root.closeActiveAccountActionsMenu() + root.closeActiveTraySubmenus() } } @@ -546,8 +1079,10 @@ Window { anchors.leftMargin: Style.trayAccountPopupHoverMargin anchors.rightMargin: Style.trayAccountPopupHoverMargin radius: Style.trayAccountPopupHoverRadius - color: settingsRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + visible: opacity > 0 + color: root.rowHoverColor + opacity: settingsRow.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } } @@ -560,7 +1095,7 @@ Window { onHoveredChanged: { if (hovered) { - root.closeActiveAccountActionsMenu() + root.closeActiveTraySubmenus() } } @@ -592,8 +1127,10 @@ Window { anchors.leftMargin: Style.trayAccountPopupHoverMargin anchors.rightMargin: Style.trayAccountPopupHoverMargin radius: Style.trayAccountPopupHoverRadius - color: quitRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + visible: opacity > 0 + color: root.rowHoverColor + opacity: quitRow.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } } } @@ -606,7 +1143,7 @@ Window { onHoveredChanged: { if (hovered) { - root.closeActiveAccountActionsMenu() + root.closeActiveTraySubmenus() } } diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 9034750791f0d..6efe7ad646a41 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -27,12 +27,372 @@ #include #include +#include + using namespace Qt::StringLiterals; namespace OCC { Q_LOGGING_CATEGORY(lcActivity, "nextcloud.gui.activity", QtInfoMsg) +namespace { +struct RecentActivityPreviewItem { + int row = -1; + Activity activity; +}; + +struct RichSubjectPreviewParameter { + QString key; + QString type; + QString label; +}; + +struct RecentActivityPreviewText { + QString title; + QString subtitle; +}; + +struct ActivityActionMetadata { + int actionIndex = -1; + ActivityLink link; +}; + +bool isSyncIssue(const Activity &activity) +{ + if (activity._type == Activity::SyncResultType) { + return true; + } + + if (activity._type != Activity::SyncFileItemType) { + return false; + } + + return activity._syncFileItemStatus != SyncFileItem::NoStatus + && activity._syncFileItemStatus != SyncFileItem::Success; +} + +bool isRecentActivityPreviewCandidate(const Activity &activity) +{ + switch (activity._type) { + case Activity::ActivityType: + return true; + case Activity::SyncFileItemType: + return !isSyncIssue(activity); + case Activity::NotificationType: + case Activity::OpenSettingsNotificationType: + case Activity::SyncResultType: + case Activity::DummyFetchingActivityType: + case Activity::DummyMoreActivitiesAvailableType: + return false; + } + return false; +} + +bool isNotificationPreviewCandidate(const Activity &activity) +{ + return activity._type == Activity::NotificationType + || activity._type == Activity::OpenSettingsNotificationType; +} + +bool isDismissableActivity(const Activity &activity) +{ + return !activity._links.isEmpty() + && activity._syncFileItemStatus != SyncFileItem::FileNameClash + && activity._syncFileItemStatus != SyncFileItem::Conflict + && activity._syncFileItemStatus != SyncFileItem::FileNameInvalid + && activity._syncFileItemStatus != SyncFileItem::FileNameInvalidOnServer; +} + +QVector activityActionMetadata(const Activity &activity, const int maximumActionIndex = -1) +{ + auto actions = QVector{}; + actions.reserve(activity._links.size()); + + for (auto i = 0; i < activity._links.size(); ++i) { + if (maximumActionIndex >= 0 && i > maximumActionIndex) { + break; + } + + const auto &activityLink = activity._links.at(i); + + // Use the isDismissable model role to present custom dismiss button if needed. + // Also don't show "View chat" for talk activities, default action will open chat anyway. + const auto isUseCustomDeleteAction = activityLink._verb == QByteArrayLiteral("DELETE") + && activity._objectType != QStringLiteral("remote_share"); + const auto isTalkWebAction = activityLink._verb == QByteArrayLiteral("WEB") + && activity._objectType == QStringLiteral("chat"); + if (isUseCustomDeleteAction || isTalkWebAction || activityLink._label.isEmpty()) { + continue; + } + + actions.push_back({i, activityLink}); + } + + return actions; +} + +QVariantMap notificationPreviewAction(const QString &label, const QString &actionType, const int actionIndex = -1, const QByteArray &verb = {}) +{ + auto action = QVariantMap{ + {QStringLiteral("label"), label}, + {QStringLiteral("actionType"), actionType}, + {QStringLiteral("actionIndex"), actionIndex}, + }; + + if (!verb.isEmpty()) { + action.insert(QStringLiteral("verb"), QString::fromUtf8(verb.constData(), verb.size())); + } + + return action; +} + +QVariantList notificationPreviewActions(const Activity &activity) +{ + auto actions = QVariantList{}; + + if (isDismissableActivity(activity)) { + actions.push_back(notificationPreviewAction( + QCoreApplication::translate("ActivityItemContent", "Dismiss"), + QStringLiteral("dismiss"), + -1, + QByteArrayLiteral("DELETE"))); + } + + for (const auto &action : activityActionMetadata(activity)) { + const auto &link = action.link; + + actions.push_back(notificationPreviewAction( + link._label, + link._verb == QByteArrayLiteral("REPLY") ? QStringLiteral("openActivities") : QStringLiteral("trigger"), + action.actionIndex, + link._verb)); + } + + return actions; +} + +QString compactPathLabel(const QString &path) +{ + const auto trimmedPath = path.trimmed(); + if (trimmedPath.isEmpty()) { + return {}; + } + + const auto fileName = QFileInfo(trimmedPath).fileName(); + return fileName.isEmpty() || fileName == "."_L1 ? trimmedPath : fileName; +} + +QString normalizedPreviewText(QString text) +{ + text.replace(QRegularExpression(QStringLiteral("\\s+")), QStringLiteral(" ")); + return text.trimmed(); +} + +QString activitySubjectText(const Activity &activity) +{ + if (!activity._subjectDisplay.isEmpty()) { + return activity._subjectDisplay; + } + return activity._subject; +} + +QString richSubjectParameterLabel(const Activity::RichSubjectParameter ¶meter) +{ + return compactPathLabel(!parameter.path.isEmpty() ? parameter.path : parameter.name); +} + +QVector richSubjectPreviewParameters(const QVariantMap ¶meters) +{ + auto previewParameters = QVector{}; + previewParameters.reserve(parameters.size()); + + for (auto it = parameters.cbegin(); it != parameters.cend(); ++it) { + const auto parameter = it.value().value(); + const auto label = richSubjectParameterLabel(parameter); + if (label.isEmpty()) { + continue; + } + + previewParameters.push_back({it.key(), parameter.type, label}); + } + + return previewParameters; +} + +RichSubjectPreviewParameter firstRichSubjectPreviewParameterByType( + const QVector ¶meters, + const QStringList &types) +{ + for (const auto &type : types) { + for (const auto ¶meter : parameters) { + if (parameter.type == type) { + return parameter; + } + } + } + return {}; +} + +RichSubjectPreviewParameter firstRichSubjectPreviewObjectParameter(const Activity &activity) +{ + const auto parameters = richSubjectPreviewParameters(activity._subjectRichParameters); + const auto fileParameter = firstRichSubjectPreviewParameterByType(parameters, { + QStringLiteral("file"), + QStringLiteral("files"), + }); + if (!fileParameter.label.isEmpty()) { + return fileParameter; + } + + const auto objectParameter = firstRichSubjectPreviewParameterByType(parameters, { + QStringLiteral("event"), + QStringLiteral("calendar-event"), + QStringLiteral("task"), + QStringLiteral("todo"), + QStringLiteral("deck-card"), + QStringLiteral("deck-board"), + }); + if (!objectParameter.label.isEmpty()) { + return objectParameter; + } + + for (const auto ¶meter : parameters) { + if (parameter.type != "user"_L1 && parameter.type != "calendar"_L1) { + return parameter; + } + } + + for (const auto ¶meter : parameters) { + if (parameter.type != "user"_L1) { + return parameter; + } + } + + return {}; +} + +QString subjectWithoutRichParameter(const Activity &activity, const QString ¶meterKey) +{ + if (activity._subjectRich.isEmpty() || parameterKey.isEmpty()) { + return activitySubjectText(activity); + } + + auto displayString = activity._subjectRich; + for (auto it = activity._subjectRichParameters.cbegin(); it != activity._subjectRichParameters.cend(); ++it) { + const auto parameter = it.value().value(); + const auto replacement = it.key() == parameterKey ? QString{} : parameter.name; + displayString.replace(QStringLiteral("{%1}").arg(it.key()), replacement); + } + + return normalizedPreviewText(displayString); +} + +RecentActivityPreviewText recentActivityPreviewText(const Activity &activity) +{ + auto title = QString{}; + auto subtitle = QString{}; + auto richParameter = RichSubjectPreviewParameter{}; + + for (const auto &preview : activity._previews) { + title = compactPathLabel(preview._filename); + if (!title.isEmpty()) { + break; + } + } + + if (title.isEmpty()) { + richParameter = firstRichSubjectPreviewObjectParameter(activity); + title = richParameter.label; + } + + if (title.isEmpty()) { + for (const auto &candidate : {activity._objectName, activity._file, activity._renamedFile}) { + title = compactPathLabel(candidate); + if (!title.isEmpty()) { + break; + } + } + } + + if (activity._type == Activity::SyncFileItemType) { + subtitle = !activity._message.isEmpty() ? activity._message : activitySubjectText(activity); + } else { + subtitle = subjectWithoutRichParameter(activity, richParameter.key); + } + + if (title.isEmpty()) { + title = activitySubjectText(activity); + subtitle = activity._message; + } + + if (subtitle == title) { + subtitle = activity._message; + } + + return {title, subtitle}; +} + +QString compactNotificationTitle(const Activity &activity) +{ + if (!activity._subjectDisplay.isEmpty()) { + return activity._subjectDisplay; + } + if (!activity._subject.isEmpty()) { + return activity._subject; + } + return activity._message; +} + +QString recentActivitySystemIconName(const Activity &activity) +{ + switch (activity._type) { + case Activity::NotificationType: + case Activity::OpenSettingsNotificationType: + return QStringLiteral("bell"); + case Activity::SyncResultType: + return QStringLiteral("exclamationmark.triangle"); + case Activity::SyncFileItemType: + if (activity._syncFileItemStatus == SyncFileItem::NormalError + || activity._syncFileItemStatus == SyncFileItem::FatalError + || activity._syncFileItemStatus == SyncFileItem::DetailError + || activity._syncFileItemStatus == SyncFileItem::BlacklistedError) { + return QStringLiteral("exclamationmark.triangle"); + } + if (activity._syncFileItemStatus == SyncFileItem::SoftError + || activity._syncFileItemStatus == SyncFileItem::Conflict + || activity._syncFileItemStatus == SyncFileItem::Restoration + || activity._syncFileItemStatus == SyncFileItem::FileLocked + || activity._syncFileItemStatus == SyncFileItem::FileNameInvalid + || activity._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer + || activity._syncFileItemStatus == SyncFileItem::FileNameClash) { + return QStringLiteral("exclamationmark.triangle"); + } + if (activity._fileAction == "file_deleted"_L1) { + return QStringLiteral("trash"); + } + if (activity._fileAction == "file_renamed"_L1) { + return QStringLiteral("pencil"); + } + return QStringLiteral("doc"); + case Activity::ActivityType: + if (activity._objectType == "chat"_L1 || activity._objectType == "room"_L1 || activity._objectType == "call"_L1) { + return QStringLiteral("message"); + } + if (activity._objectType == "calendar"_L1) { + return QStringLiteral("calendar"); + } + if (activity._fileAction == "file_deleted"_L1) { + return QStringLiteral("trash"); + } + return QStringLiteral("doc"); + case Activity::DummyFetchingActivityType: + case Activity::DummyMoreActivitiesAvailableType: + return QStringLiteral("clock"); + } + return QStringLiteral("doc"); +} +} + ActivityListModel::ActivityListModel(QObject *parent) : QAbstractListModel(parent) { @@ -400,10 +760,124 @@ int ActivityListModel::rowCount(const QModelIndex &parent) const return _finalList.count(); } +QVariantList ActivityListModel::recentActivityPreviewData(const int limit) const +{ + if (limit == 5) { + return _recentActivityPreviewData; + } + + return buildRecentActivityPreviewData(limit); +} + +QVariantList ActivityListModel::notificationPreviewData() const +{ + return _notificationPreviewData; +} + +QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) const +{ + if (limit <= 0) { + return {}; + } + + auto candidates = QVector{}; + candidates.reserve(_finalList.size()); + + for (auto row = 0; row < _finalList.size(); ++row) { + const auto activity = _finalList.at(row); + if (isRecentActivityPreviewCandidate(activity)) { + candidates.push_back({row, activity}); + } + } + + std::stable_sort(candidates.begin(), candidates.end(), [](const auto &left, const auto &right) { + return left.activity._dateTime > right.activity._dateTime; + }); + + auto recentActivities = QVariantList{}; + for (const auto &candidate : std::as_const(candidates)) { + const auto previewText = recentActivityPreviewText(candidate.activity); + if (previewText.title.isEmpty()) { + continue; + } + + const auto modelIndex = index(candidate.row); + recentActivities.push_back(QVariantMap{ + {QStringLiteral("title"), previewText.title}, + {QStringLiteral("subtitle"), previewText.subtitle}, + {QStringLiteral("icon"), data(modelIndex, IconRole).toString()}, + {QStringLiteral("systemIconName"), recentActivitySystemIconName(candidate.activity)}, + {QStringLiteral("dateTime"), data(modelIndex, PointInTimeRole).toString()}, + {QStringLiteral("activityIndex"), candidate.row}, + }); + + if (recentActivities.size() >= limit) { + break; + } + } + + return recentActivities; +} + +QVariantList ActivityListModel::buildNotificationPreviewData() const +{ + auto candidates = QVector{}; + candidates.reserve(_finalList.size()); + + for (auto row = 0; row < _finalList.size(); ++row) { + const auto activity = _finalList.at(row); + if (isNotificationPreviewCandidate(activity)) { + candidates.push_back({row, activity}); + } + } + + std::stable_sort(candidates.begin(), candidates.end(), [](const auto &left, const auto &right) { + return left.activity._dateTime > right.activity._dateTime; + }); + + auto notifications = QVariantList{}; + for (const auto &candidate : std::as_const(candidates)) { + const auto title = compactNotificationTitle(candidate.activity); + if (title.isEmpty()) { + continue; + } + + const auto modelIndex = index(candidate.row); + const auto actions = notificationPreviewActions(candidate.activity); + notifications.push_back(QVariantMap{ + {QStringLiteral("title"), title}, + {QStringLiteral("icon"), data(modelIndex, IconRole).toString()}, + {QStringLiteral("systemIconName"), recentActivitySystemIconName(candidate.activity)}, + {QStringLiteral("dateTime"), data(modelIndex, PointInTimeRole).toString()}, + {QStringLiteral("activityIndex"), candidate.row}, + {QStringLiteral("opensSettings"), candidate.activity._type == Activity::OpenSettingsNotificationType}, + {QStringLiteral("canDismiss"), isDismissableActivity(candidate.activity)}, + {QStringLiteral("actions"), actions}, + }); + } + + return notifications; +} + +void ActivityListModel::refreshPreviewData() +{ + const auto recentActivityPreviewData = buildRecentActivityPreviewData(5); + if (_recentActivityPreviewData != recentActivityPreviewData) { + _recentActivityPreviewData = recentActivityPreviewData; + emit recentActivityPreviewDataChanged(); + } + + const auto notificationPreviewData = buildNotificationPreviewData(); + if (_notificationPreviewData != notificationPreviewData) { + _notificationPreviewData = notificationPreviewData; + emit notificationPreviewDataChanged(); + } +} + bool ActivityListModel::canFetchMore(const QModelIndex &) const { // We need to be connected to be able to fetch more - if (_accountState && _accountState->isConnected() && Systray::instance()->isOpen()) { + if (_accountState && _accountState->isConnected() && Systray::instance()->isActivitySurfaceVisible()) { // If the fetching is reported to be done or we are currently fetching we can't fetch more if (!_doneFetching && !currentlyFetching()) { return true; @@ -568,8 +1042,9 @@ void ActivityListModel::addEntriesToActivityList(const ActivityList &activityLis } } endInsertRows(); - setHasSyncConflicts(!_conflictsList.isEmpty()); + refreshPreviewData(); + emit activityListChanged(); } void ActivityListModel::accountStateHasChanged() @@ -646,6 +1121,7 @@ void ActivityListModel::removeActivityFromActivityList(int row) void ActivityListModel::removeActivityFromActivityList(const Activity &activity) { const auto index = _finalList.indexOf(activity); + const auto activityWasRemoved = index != -1; if (index != -1) { beginRemoveRows({}, index, index); _finalList.removeAt(index); @@ -671,6 +1147,12 @@ void ActivityListModel::removeActivityFromActivityList(const Activity &activity) _notificationErrorsLists.removeAt(notificationErrorsListIndex); } } + + if (activityWasRemoved) { + setHasSyncConflicts(!_conflictsList.isEmpty()); + refreshPreviewData(); + emit activityListChanged(); + } } #ifdef BUILD_FILE_PROVIDER_MODULE @@ -940,24 +1422,14 @@ QVariantList ActivityListModel::convertLinksToActionButtons(const Activity &acti { QVariantList customList; - for (int i = 0; i < activity._links.size() && static_cast(i) <= maxActionButtons(); ++i) { - const auto activityLink = activity._links[i]; - - // Use the isDismissable model role to present custom dismiss button if needed - // Also don't show "View chat" for talk activities, default action will open chat anyway - const auto isUseCustomDeleteAction = activityLink._verb == "DELETE" - && activity._objectType != QStringLiteral("remote_share"); - if (isUseCustomDeleteAction || (activityLink._verb == "WEB" && activity._objectType == "chat")) { - continue; - } - - customList << ActivityListModel::convertLinkToActionButton(activityLink); + for (const auto &action : activityActionMetadata(activity, maxActionButtons())) { + customList << ActivityListModel::convertLinkToActionButton(action.link, action.actionIndex); } return customList; } -QVariant ActivityListModel::convertLinkToActionButton(const OCC::ActivityLink &activityLink) +QVariant ActivityListModel::convertLinkToActionButton(const OCC::ActivityLink &activityLink, const int actionIndex) { auto activityLinkCopy = activityLink; @@ -970,7 +1442,15 @@ QVariant ActivityListModel::convertLinkToActionButton(const OCC::ActivityLink &a activityLinkCopy._imageSourceHovered = QString(replyButtonPath + "/"); } - return QVariant::fromValue(activityLinkCopy); + return QVariantMap{ + {QStringLiteral("actionIndex"), actionIndex}, + {QStringLiteral("imageSource"), activityLinkCopy._imageSource}, + {QStringLiteral("imageSourceHovered"), activityLinkCopy._imageSourceHovered}, + {QStringLiteral("label"), activityLinkCopy._label}, + {QStringLiteral("link"), activityLinkCopy._link}, + {QStringLiteral("primary"), activityLinkCopy._primary}, + {QStringLiteral("verb"), QString::fromUtf8(activityLinkCopy._verb.constData(), activityLinkCopy._verb.size())}, + }; } QVariantList ActivityListModel::convertLinksToMenuEntries(const Activity &activity) @@ -1033,6 +1513,9 @@ void ActivityListModel::slotRemoveAccount() _doneFetching = false; _currentItem = 0; _showMoreActivitiesAvailableEntry = false; + setHasSyncConflicts(false); + refreshPreviewData(); + emit activityListChanged(); } void ActivityListModel::setReplyMessageSent(const int activityIndex, const QString &message) diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 299b1de756fde..21512cad943a6 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -96,6 +96,8 @@ class ActivityListModel : public QAbstractListModel [[nodiscard]] const ActivityList& activityList() const { return _finalList; } [[nodiscard]] const ActivityList& errorsList() const { return _notificationErrorsLists; } + [[nodiscard]] QVariantList recentActivityPreviewData(int limit = 5) const; + [[nodiscard]] QVariantList notificationPreviewData() const; [[nodiscard]] AccountState *accountState() const; @@ -155,6 +157,9 @@ public slots: void accountStateChanged(); void hasSyncConflictsChanged(); void allConflictsChanged(); + void recentActivityPreviewDataChanged(); + void notificationPreviewDataChanged(); + void activityListChanged(); void activityJobStatusCode(int statusCode); void sendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); @@ -196,12 +201,15 @@ private slots: private: static QVariantList convertLinksToMenuEntries(const Activity &activity); static QVariantList convertLinksToActionButtons(const Activity &activity); - static QVariant convertLinkToActionButton(const ActivityLink &activityLink); + static QVariant convertLinkToActionButton(const ActivityLink &activityLink, int actionIndex); [[nodiscard]] bool canFetchActivities() const; void displaySingleConflictDialog(const Activity &activity); void setHasSyncConflicts(bool conflictsFound); + [[nodiscard]] QVariantList buildRecentActivityPreviewData(int limit) const; + [[nodiscard]] QVariantList buildNotificationPreviewData() const; + void refreshPreviewData(); Activity _notificationIgnoredFiles; Activity _dummyFetchingActivities; @@ -210,6 +218,8 @@ private slots: ActivityList _notificationErrorsLists; ActivityList _conflictsList; ActivityList _finalList; + QVariantList _recentActivityPreviewData; + QVariantList _notificationPreviewData; QSet _presentedActivities; QSet _activeNotificationIds; diff --git a/src/gui/tray/asyncimageresponse.cpp b/src/gui/tray/asyncimageresponse.cpp index 875a2d16eb755..b9e4db7ff421a 100644 --- a/src/gui/tray/asyncimageresponse.cpp +++ b/src/gui/tray/asyncimageresponse.cpp @@ -14,6 +14,31 @@ using namespace Qt::StringLiterals; +namespace { + +QImage aspectFitImage(const QImage &sourceImage, const QSize &requestedSize) +{ + if (sourceImage.isNull() || !requestedSize.isValid()) { + return sourceImage; + } + + return sourceImage.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); +} + +QRectF aspectFitRect(const QSize &sourceSize, const QSize &targetSize) +{ + if (!sourceSize.isValid() || !targetSize.isValid()) { + return QRectF(QPointF(0.0, 0.0), targetSize); + } + + const auto scaledSize = sourceSize.scaled(targetSize, Qt::KeepAspectRatio); + return QRectF(QPointF((targetSize.width() - scaledSize.width()) / 2.0, + (targetSize.height() - scaledSize.height()) / 2.0), + scaledSize); +} + +} + AsyncImageResponse::AsyncImageResponse(const QString &id, const QSize &requestedSize) { if (id.isEmpty()) { @@ -122,7 +147,7 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) if (const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); !(mimetype.isValid() && mimetype.inherits("image/svg+xml"_L1))) { // Not an SVG: let's let QImage deal with the response. - setImageAndEmitFinished(QImage::fromData(imageData).scaled(_requestedImageSize)); + setImageAndEmitFinished(aspectFitImage(QImage::fromData(imageData), _requestedImageSize)); return; } @@ -136,7 +161,7 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32); scaledSvg.fill("transparent"); QPainter painterForSvg(&scaledSvg); - svgRenderer.render(&painterForSvg); + svgRenderer.render(&painterForSvg, aspectFitRect(svgRenderer.defaultSize(), _requestedImageSize)); if (!_svgRecolor.isValid()) { setImageAndEmitFinished(scaledSvg); @@ -150,4 +175,3 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) imagePainter.drawImage(0, 0, scaledSvg); setImageAndEmitFinished(image); } - diff --git a/src/gui/tray/syncstatussummary.cpp b/src/gui/tray/syncstatussummary.cpp index 159239f5f05c0..b9d1c800a775d 100644 --- a/src/gui/tray/syncstatussummary.cpp +++ b/src/gui/tray/syncstatussummary.cpp @@ -88,11 +88,16 @@ bool SyncStatusSummary::reloadNeeded(AccountState *accountState) const void SyncStatusSummary::load() { - const auto currentUser = UserModel::instance()->currentUser(); - if (!currentUser) { + loadForUser(UserModel::instance()->currentUser()); +} + +void SyncStatusSummary::loadForUser(User *user) +{ + if (!user) { return; } - setAccountState(currentUser->accountState()); + + setAccountState(user->accountState()); clearFolderErrors(); connectToFoldersProgress(FolderMan::instance()->map()); initSyncState(); diff --git a/src/gui/tray/syncstatussummary.h b/src/gui/tray/syncstatussummary.h index 203c5be5f1677..b2de47ae1f170 100644 --- a/src/gui/tray/syncstatussummary.h +++ b/src/gui/tray/syncstatussummary.h @@ -17,6 +17,8 @@ namespace OCC { +class User; + class SyncStatusSummary : public QObject { Q_OBJECT @@ -51,6 +53,7 @@ class SyncStatusSummary : public QObject public slots: void load(); + void loadForUser(OCC::User *user); private: void connectToFoldersProgress(const Folder::Map &map); diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp index 54c8c19c20005..25963f9c6b64d 100644 --- a/src/gui/tray/trayaccountappsmodel.cpp +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -28,6 +28,16 @@ TrayAccountAppsModel::TrayAccountAppsModel(QObject *parent) void TrayAccountAppsModel::setUserId(const int userId) { + const auto apps = appsForUserId(userId); + if (_userId == userId && _apps == apps) { + return; + } + + _userId = userId; + if (_apps == apps) { + return; + } + const auto oldCount = _apps.size(); if (!_apps.isEmpty()) { @@ -36,22 +46,30 @@ void TrayAccountAppsModel::setUserId(const int userId) endRemoveRows(); } + if (!apps.isEmpty()) { + beginInsertRows(QModelIndex(), 0, apps.size() - 1); + _apps = apps; + endInsertRows(); + } + + if (_apps.size() != oldCount) { + emit countChanged(); + } +} + +AccountAppList TrayAccountAppsModel::appsForUserId(const int userId) const +{ const auto accounts = AccountManager::instance()->accounts(); if (userId < 0 || userId >= accounts.size()) { - if (oldCount != _apps.size()) { - emit countChanged(); - } - return; + return {}; } const auto account = accounts.at(userId); if (!account) { - if (oldCount != _apps.size()) { - emit countChanged(); - } - return; + return {}; } + auto apps = AccountAppList{}; const auto allApps = account->appList(); const auto talkApp = account->findApp(QStringLiteral("spreed")); const auto assistantEnabled = account->account()->capabilities().ncAssistantEnabled(); @@ -61,14 +79,10 @@ void TrayAccountAppsModel::setUserId(const int userId) continue; } - beginInsertRows(QModelIndex(), _apps.size(), _apps.size()); - _apps << app; - endInsertRows(); + apps << app; } - if (_apps.size() != oldCount) { - emit countChanged(); - } + return apps; } void TrayAccountAppsModel::openAppUrl(const QUrl &url) diff --git a/src/gui/tray/trayaccountappsmodel.h b/src/gui/tray/trayaccountappsmodel.h index 0c91aa8eee818..79337528c3e5b 100644 --- a/src/gui/tray/trayaccountappsmodel.h +++ b/src/gui/tray/trayaccountappsmodel.h @@ -44,7 +44,10 @@ class TrayAccountAppsModel : public QAbstractListModel private: explicit TrayAccountAppsModel(QObject *parent = nullptr); + [[nodiscard]] AccountAppList appsForUserId(int userId) const; + static TrayAccountAppsModel *_instance; + int _userId = -1; AccountAppList _apps; }; diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index b7ea769b2880f..9d9cdc7826d93 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -147,6 +147,12 @@ struct SyncStatusInfo { bool ok = true; }; +enum class SyncIssueKind { + None, + Warning, + Error, +}; + OCC::SyncResult::Status determineSyncStatus(const OCC::SyncResult &syncResult) { const auto status = syncResult.status(); @@ -217,6 +223,7 @@ SyncStatusInfo syncStatusForAccount(const OCC::AccountStatePtr &accountState) hasError = true; break; case OCC::SyncResult::Problem: + case OCC::SyncResult::Undefined: hasWarning = true; break; case OCC::SyncResult::Paused: @@ -229,7 +236,6 @@ SyncStatusInfo syncStatusForAccount(const OCC::AccountStatePtr &accountState) break; case OCC::SyncResult::Success: case OCC::SyncResult::NotYetStarted: - case OCC::SyncResult::Undefined: break; } } @@ -260,6 +266,7 @@ bool isSyncStatusError(const OCC::SyncResult::Status status) case OCC::SyncResult::Error: case OCC::SyncResult::SetupError: case OCC::SyncResult::Problem: + case OCC::SyncResult::Undefined: return true; case OCC::SyncResult::Success: case OCC::SyncResult::SyncPrepare: @@ -267,9 +274,159 @@ bool isSyncStatusError(const OCC::SyncResult::Status status) case OCC::SyncResult::NotYetStarted: case OCC::SyncResult::Paused: case OCC::SyncResult::SyncAbortRequested: + return false; + } + return false; +} + +SyncIssueKind syncIssueKindForSyncResult(const OCC::SyncResult::Status status) +{ + switch (status) { + case OCC::SyncResult::Error: + case OCC::SyncResult::SetupError: + return SyncIssueKind::Error; + case OCC::SyncResult::Problem: case OCC::SyncResult::Undefined: + return SyncIssueKind::Warning; + case OCC::SyncResult::Success: + case OCC::SyncResult::SyncPrepare: + case OCC::SyncResult::SyncRunning: + case OCC::SyncResult::NotYetStarted: + case OCC::SyncResult::Paused: + case OCC::SyncResult::SyncAbortRequested: + return SyncIssueKind::None; + } + return SyncIssueKind::None; +} + +SyncIssueKind syncIssueKindForSyncFileItem(const OCC::SyncFileItem::Status status) +{ + switch (status) { + case OCC::SyncFileItem::NormalError: + case OCC::SyncFileItem::FatalError: + case OCC::SyncFileItem::DetailError: + case OCC::SyncFileItem::BlacklistedError: + return SyncIssueKind::Error; + case OCC::SyncFileItem::SoftError: + case OCC::SyncFileItem::Conflict: + case OCC::SyncFileItem::FileIgnored: + case OCC::SyncFileItem::Restoration: + case OCC::SyncFileItem::FileLocked: + case OCC::SyncFileItem::FileNameInvalid: + case OCC::SyncFileItem::FileNameInvalidOnServer: + case OCC::SyncFileItem::FileNameClash: + return SyncIssueKind::Warning; + case OCC::SyncFileItem::NoStatus: + case OCC::SyncFileItem::Success: + return SyncIssueKind::None; + } + return SyncIssueKind::None; +} + +SyncIssueKind strongestSyncIssueKind(const SyncIssueKind left, const SyncIssueKind right) +{ + if (left == SyncIssueKind::Error || right == SyncIssueKind::Error) { + return SyncIssueKind::Error; + } + if (left == SyncIssueKind::Warning || right == SyncIssueKind::Warning) { + return SyncIssueKind::Warning; + } + return SyncIssueKind::None; +} + +SyncIssueKind syncIssueKindForActivity(const OCC::Activity &activity) +{ + if (activity._type == OCC::Activity::SyncResultType) { + return syncIssueKindForSyncResult(activity._syncResultStatus); + } + if (activity._type == OCC::Activity::SyncFileItemType) { + return syncIssueKindForSyncFileItem(activity._syncFileItemStatus); + } + return SyncIssueKind::None; +} + +SyncIssueKind syncIssueKindForActivities(const OCC::ActivityListModel *activityModel) +{ + if (!activityModel) { + return SyncIssueKind::None; + } + + auto result = SyncIssueKind::None; + for (const auto &activity : activityModel->activityList()) { + result = strongestSyncIssueKind(result, syncIssueKindForActivity(activity)); + if (result == SyncIssueKind::Error) { + return result; + } + } + return result; +} + +bool accountHasSyncErrors(const OCC::AccountStatePtr &accountState) +{ + if (!accountState || !accountState->isConnected()) { return false; } + + for (const auto folder : OCC::FolderMan::instance()->map().values()) { + if (folder->accountState() != accountState.data()) { + continue; + } + const auto status = determineSyncStatus(folder->syncResult()); + if (isSyncStatusError(status)) { + return true; + } + } + +#ifdef BUILD_FILE_PROVIDER_MODULE + const auto fileProviderStatus = OCC::Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(accountState->account()); + if (isSyncStatusError(fileProviderStatus)) { + return true; + } +#endif + + return false; +} + +SyncIssueKind syncIssueKindForAccount(const OCC::AccountStatePtr &accountState) +{ + if (!accountState || !accountState->isConnected()) { + return SyncIssueKind::None; + } + + auto result = SyncIssueKind::None; + for (const auto folder : OCC::FolderMan::instance()->map().values()) { + if (folder->accountState() != accountState.data()) { + continue; + } + result = strongestSyncIssueKind(result, syncIssueKindForSyncResult(determineSyncStatus(folder->syncResult()))); + if (result == SyncIssueKind::Error) { + return result; + } + } + +#ifdef BUILD_FILE_PROVIDER_MODULE + const auto fileProviderStatus = OCC::Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(accountState->account()); + result = strongestSyncIssueKind(result, syncIssueKindForSyncResult(fileProviderStatus)); +#endif + + return result; +} + +bool accountNeedsSandboxReapproval(const OCC::AccountStatePtr &accountState) +{ +#ifdef Q_OS_MACOS + if (!accountState || !accountState->isConnected()) { + return false; + } + + for (const auto folder : OCC::FolderMan::instance()->map().values()) { + if (folder && folder->accountState() == accountState.data() && folder->needsSandboxBookmark()) { + return true; + } + } +#else + Q_UNUSED(accountState) +#endif return false; } @@ -356,6 +513,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account.data(), &AccountState::stateChanged, [=, this]() { if (isConnected()) {slotRefreshImmediately();} }); connect(_account.data(), &AccountState::stateChanged, this, &User::accountStateChanged); + connect(_account.data(), &AccountState::stateChanged, this, &User::refreshAccountAlert); connect(_account.data(), &AccountState::hasFetchedNavigationApps, this, &User::slotRebuildNavigationAppList); connect(_account->account().data(), &Account::accountChangedDisplayName, this, &User::nameChanged); @@ -377,6 +535,10 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest); connect(_activityModel, &ActivityListModel::showSettingsDialog, Systray::instance(), &Systray::openSettings); + connect(_activityModel, &ActivityListModel::hasSyncConflictsChanged, this, &User::refreshAccountAlert); + connect(_activityModel, &ActivityListModel::recentActivityPreviewDataChanged, this, &User::recentActivitiesChanged); + connect(_activityModel, &ActivityListModel::notificationPreviewDataChanged, this, &User::trayNotificationsChanged); + connect(_activityModel, &ActivityListModel::activityListChanged, this, &User::refreshAccountAlert); connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage); @@ -405,19 +567,24 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(folderMan, &FolderMan::folderSyncStateChange, this, [this](const Folder *folder) { if (!folder || folder->accountState() == _account.data()) { updateSyncStatus(); + refreshAccountAlert(); } }); connect(folderMan, &FolderMan::folderListChanged, this, [this](const Folder::Map &) { updateSyncStatus(); + refreshAccountAlert(); }); connect(_account.data(), &AccountState::isConnectedChanged, this, &User::updateSyncStatus); + connect(_account.data(), &AccountState::isConnectedChanged, this, &User::refreshAccountAlert); updateSyncStatus(); + refreshAccountAlert(); #ifdef BUILD_FILE_PROVIDER_MODULE connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::syncStateChanged, this, [this](const OCC::AccountPtr &account, OCC::SyncResult::Status) { if (account == _account->account()) { updateSyncStatus(); + refreshAccountAlert(); } }); connect(Mac::FileProvider::instance()->service(), &Mac::FileProviderService::itemExcludedFromSync, @@ -927,7 +1094,7 @@ bool User::checkPushNotificationsAreReady() const } void User::slotRefreshImmediately() { - if (_account.data() && _account.data()->isConnected() && Systray::instance()->isOpen()) { + if (_account.data() && _account.data()->isConnected() && Systray::instance()->isActivitySurfaceVisible()) { slotRefreshActivities(); } slotRefreshNotifications(); @@ -964,14 +1131,34 @@ void User::slotRefresh() void User::slotRefreshActivitiesInitial() { - if (_account.data()->isConnected() && Systray::instance()->isOpen()) { + if (_account.data()->isConnected() && Systray::instance()->isActivitySurfaceVisible()) { + _activityModel->slotRefreshActivityInitial(); + } +} + +void User::slotRefreshActivityPreview() +{ + if (!_account.data() || !_account.data()->isConnected() || !Systray::instance()->isActivitySurfaceVisible()) { + return; + } + + if (!_timeSinceLastActivityPreviewCheck.isValid()) { _activityModel->slotRefreshActivityInitial(); + _timeSinceLastActivityPreviewCheck.start(); + return; + } + + if (_timeSinceLastActivityPreviewCheck.elapsed() < NOTIFICATION_REQUEST_FREE_PERIOD) { + return; } + + _activityModel->slotRefreshActivity(); + _timeSinceLastActivityPreviewCheck.start(); } void User::slotRefreshActivities() { - if (_account.data()->isConnected() && Systray::instance()->isOpen()) { + if (_account.data()->isConnected() && Systray::instance()->isActivitySurfaceVisible()) { _activityModel->slotRefreshActivity(); } } @@ -1452,6 +1639,73 @@ ActivityListModel *User::getActivityModel() return _activityModel; } +QVariantList User::recentActivities() const +{ + return _activityModel->recentActivityPreviewData(); +} + +QVariantList User::trayNotifications() const +{ + return _activityModel->notificationPreviewData(); +} + +QVariantMap User::accountAlert() const +{ + return _accountAlert; +} + +QVariantMap User::buildAccountAlert() const +{ + if (_activityModel->hasSyncConflicts()) { + return { + {QStringLiteral("title"), tr("Sync conflicts")}, + {QStringLiteral("icon"), Theme::instance()->warning()}, + {QStringLiteral("systemIconName"), QStringLiteral("exclamationmark.triangle")}, + }; + } + + if (accountNeedsSandboxReapproval(_account)) { + return { + {QStringLiteral("title"), QCoreApplication::translate("OCC::SyncStatusSummary", "Reauthorization required")}, + {QStringLiteral("icon"), Theme::instance()->error()}, + {QStringLiteral("systemIconName"), QStringLiteral("exclamationmark.triangle")}, + }; + } + + if (needsToSignTermsOfService()) { + return { + {QStringLiteral("title"), QCoreApplication::translate("OCC::SyncStatusSummary", "You need to accept the terms of service")}, + {QStringLiteral("icon"), Theme::instance()->warning()}, + {QStringLiteral("systemIconName"), QStringLiteral("exclamationmark.triangle")}, + }; + } + + const auto syncIssueKind = strongestSyncIssueKind(syncIssueKindForActivities(_activityModel), syncIssueKindForAccount(_account)); + if (syncIssueKind != SyncIssueKind::None) { + const auto hasErrors = syncIssueKind == SyncIssueKind::Error; + return { + {QStringLiteral("title"), hasErrors + ? QCoreApplication::translate("OCC::SyncStatusSummary", "Some files couldn't be synced!") + : QCoreApplication::translate("OCC::SyncStatusSummary", "Some files could not be synced!")}, + {QStringLiteral("icon"), hasErrors ? Theme::instance()->error() : Theme::instance()->warning()}, + {QStringLiteral("systemIconName"), QStringLiteral("exclamationmark.triangle")}, + }; + } + + return {}; +} + +void User::refreshAccountAlert() +{ + const auto accountAlert = buildAccountAlert(); + if (_accountAlert == accountAlert) { + return; + } + + _accountAlert = accountAlert; + emit accountAlertChanged(); +} + UnifiedSearchResultsListModel *User::getUnifiedSearchResultsListModel() const { return _unifiedSearchResultsModel; @@ -1464,6 +1718,16 @@ void User::openLocalFolder() const } } +void User::openServer() const +{ + auto url = server(false); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + server(false); + } + + QDesktopServices::openUrl(url); +} + #ifdef BUILD_FILE_PROVIDER_MODULE void User::openFileProviderDomain() const { @@ -2361,6 +2625,22 @@ void UserModel::addUser(AccountStatePtr &user, const bool &isCurrent) UserModel::SyncStatusOkRole }); }); + connect(u, &User::recentActivitiesChanged, this, [this, row] { + emit dataChanged(index(row, 0), index(row, 0), { UserModel::RecentActivitiesRole }); + }); + + connect(u, &User::trayNotificationsChanged, this, [this, row] { + emit dataChanged(index(row, 0), index(row, 0), { UserModel::TrayNotificationsRole }); + }); + + connect(u, &User::accountAlertChanged, this, [this, row] { + emit dataChanged(index(row, 0), index(row, 0), { UserModel::AccountAlertRole }); + }); + + connect(u, &User::assistantStateChanged, this, [this, row] { + emit dataChanged(index(row, 0), index(row, 0), { UserModel::AssistantEnabledRole }); + }); + _users << u; endInsertRows(); @@ -2407,12 +2687,7 @@ void UserModel::openCurrentAccountServer() if (_currentUserId < 0 || _currentUserId >= _users.size()) return; - QString url = _users[_currentUserId]->server(false); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "https://" + _users[_currentUserId]->server(false); - } - - QDesktopServices::openUrl(url); + _users[_currentUserId]->openServer(); } void UserModel::openCurrentAccountFolderFromTrayInfo(const QString &fullRemotePath) @@ -2599,6 +2874,18 @@ QVariant UserModel::data(const QModelIndex &index, int role) const case SyncStatusOkRole: result = _users[index.row()]->syncStatusOk(); break; + case RecentActivitiesRole: + result = _users[index.row()]->recentActivities(); + break; + case AssistantEnabledRole: + result = _users[index.row()]->isNcAssistantEnabled(); + break; + case TrayNotificationsRole: + result = _users[index.row()]->trayNotifications(); + break; + case AccountAlertRole: + result = _users[index.row()]->accountAlert(); + break; } return result; @@ -2623,6 +2910,10 @@ QHash UserModel::roleNames() const roles[RemoveAccountTextRole] = "removeAccountText"; roles[SyncStatusIconRole] = "syncStatusIcon"; roles[SyncStatusOkRole] = "syncStatusOk"; + roles[RecentActivitiesRole] = "recentActivities"; + roles[AssistantEnabledRole] = "assistantEnabled"; + roles[TrayNotificationsRole] = "trayNotifications"; + roles[AccountAlertRole] = "accountAlert"; return roles; } @@ -2642,6 +2933,42 @@ void UserModel::fetchCurrentActivityModel() _users[currentUserId()]->slotRefresh(); } +void UserModel::fetchActivityModel(const int id) +{ + if (id < 0 || id >= _users.size()) { + return; + } + + _users[id]->slotRefresh(); +} + +void UserModel::fetchActivityPreview(const int id) +{ + if (id < 0 || id >= _users.size()) { + return; + } + + _users[id]->slotRefreshActivityPreview(); +} + +void UserModel::dismissNotification(const int id, const int activityIndex) +{ + if (id < 0 || id >= _users.size()) { + return; + } + + _users[id]->getActivityModel()->slotTriggerDismiss(activityIndex); +} + +void UserModel::triggerNotificationAction(const int id, const int activityIndex, const int actionIndex) +{ + if (id < 0 || id >= _users.size()) { + return; + } + + _users[id]->getActivityModel()->slotTriggerAction(activityIndex, actionIndex); +} + AccountAppList UserModel::appList() const { if (_currentUserId < 0 || _currentUserId >= _users.size()) @@ -2658,6 +2985,15 @@ User *UserModel::currentUser() const return _users[currentUserId()]; } +User *UserModel::user(const int id) const +{ + if (id < 0 || id >= _users.size()) { + return nullptr; + } + + return _users[id]; +} + User *UserModel::findUserForAccount(AccountState *account) const { Q_ASSERT(account); @@ -2693,29 +3029,7 @@ bool UserModel::userHasSyncErrors(const User *user) const return false; } - const auto accountState = user->accountState(); - if (!accountState || !accountState->isConnected()) { - return false; - } - - for (const auto folder : FolderMan::instance()->map().values()) { - if (folder->accountState() != accountState.data()) { - continue; - } - const auto status = determineSyncStatus(folder->syncResult()); - if (isSyncStatusError(status)) { - return true; - } - } - -#ifdef BUILD_FILE_PROVIDER_MODULE - const auto fileProviderStatus = Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(accountState->account()); - if (isSyncStatusError(fileProviderStatus)) { - return true; - } -#endif - - return false; + return accountHasSyncErrors(user->accountState()); } void UserModel::updateSyncErrorUsers() diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index d7b42035d9f64..a1d8bd0df76a5 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -16,6 +16,7 @@ #include #include #include +#include #include "accountfwd.h" #include "accountmanager.h" @@ -75,6 +76,9 @@ class User : public QObject Q_PROPERTY(QString featuredAppIcon READ featuredAppIcon NOTIFY featuredAppChanged) Q_PROPERTY(QString featuredAppAccessibleName READ featuredAppAccessibleName NOTIFY featuredAppChanged) Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged) + Q_PROPERTY(QVariantList recentActivities READ recentActivities NOTIFY recentActivitiesChanged) + Q_PROPERTY(QVariantList trayNotifications READ trayNotifications NOTIFY trayNotificationsChanged) + Q_PROPERTY(QVariantMap accountAlert READ accountAlert NOTIFY accountAlertChanged) Q_PROPERTY(QUrl syncStatusIcon READ syncStatusIcon NOTIFY syncStatusChanged) Q_PROPERTY(bool syncStatusOk READ syncStatusOk NOTIFY syncStatusChanged) Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged) @@ -116,6 +120,9 @@ class User : public QObject [[nodiscard]] bool isFeaturedAppEnabled() const; [[nodiscard]] QString featuredAppIcon() const; [[nodiscard]] QString featuredAppAccessibleName() const; + [[nodiscard]] QVariantList recentActivities() const; + [[nodiscard]] QVariantList trayNotifications() const; + [[nodiscard]] QVariantMap accountAlert() const; [[nodiscard]] bool serverHasUserStatus() const; [[nodiscard]] AccountApp *talkApp() const; [[nodiscard]] bool hasActivities() const; @@ -154,6 +161,9 @@ class User : public QObject void hasLocalFolderChanged(); void featuredAppChanged(); void avatarChanged(); + void recentActivitiesChanged(); + void trayNotificationsChanged(); + void accountAlertChanged(); void accountStateChanged(); void statusChanged(); void desktopNotificationsAllowedChanged(); @@ -186,6 +196,7 @@ public slots: void slotBuildIncomingCallDialogs(const OCC::ActivityList &list); void slotRefreshNotifications(); void slotRefreshActivitiesInitial(); + void slotRefreshActivityPreview(); void slotRefreshActivities(); void slotRefresh(); void slotRefreshUserStatus(); @@ -194,6 +205,7 @@ public slots: void slotRebuildNavigationAppList(); void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void forceSyncNow() const; + void openServer() const; void slotAccountCapabilitiesChangedRefreshGroupFolders(); void slotFetchGroupFolders(); @@ -251,6 +263,8 @@ private slots: bool isActivityOfCurrentAccount(const Folder *folder) const; [[nodiscard]] bool isUnsolvableConflict(const SyncFileItemPtr &item) const; void updateSyncStatus(); + [[nodiscard]] QVariantMap buildAccountAlert() const; + void refreshAccountAlert(); bool notificationAlreadyShown(const qint64 notificationId); bool canShowNotification(const qint64 notificationId); @@ -261,12 +275,14 @@ private slots: bool _isCurrentUser; ActivityListModel *_activityModel; UnifiedSearchResultsListModel *_unifiedSearchResultsModel; + QVariantMap _accountAlert; QVariantList _trayFolderInfos; QTimer _expiredActivitiesCheckTimer; QTimer _notificationCheckTimer; QHash _timeSinceLastCheck; + QElapsedTimer _timeSinceLastActivityPreviewCheck; QElapsedTimer _guiLogTimer; QSet _notifiedNotifications; @@ -344,6 +360,7 @@ class UserModel : public QAbstractListModel [[nodiscard]] QImage syncStatusIconForRow(int row) const; [[nodiscard]] User *currentUser() const; + [[nodiscard]] User *user(int id) const; [[nodiscard]] User *findUserForAccount(AccountState *account) const; [[nodiscard]] int findUserIdForAccount(AccountState *account) const; @@ -379,6 +396,10 @@ class UserModel : public QAbstractListModel RemoveAccountTextRole, SyncStatusIconRole, SyncStatusOkRole, + RecentActivitiesRole, + AssistantEnabledRole, + TrayNotificationsRole, + AccountAlertRole, }; [[nodiscard]] AccountAppList appList() const; @@ -391,6 +412,10 @@ class UserModel : public QAbstractListModel public slots: void fetchCurrentActivityModel(); + Q_INVOKABLE void fetchActivityModel(int id); + Q_INVOKABLE void fetchActivityPreview(int id); + Q_INVOKABLE void dismissNotification(int id, int activityIndex); + Q_INVOKABLE void triggerNotificationAction(int id, int activityIndex, int actionIndex); void openCurrentAccountLocalFolder(); #ifdef BUILD_FILE_PROVIDER_MODULE void openCurrentAccountFileProviderDomain(); diff --git a/src/gui/wizard/qml/WizardDialogFrame.qml b/src/gui/wizard/qml/WizardDialogFrame.qml index 77432179d8aea..6d75ff0e71406 100644 --- a/src/gui/wizard/qml/WizardDialogFrame.qml +++ b/src/gui/wizard/qml/WizardDialogFrame.qml @@ -7,13 +7,15 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Style + Pane { id: root default property alias contents: body.data property alias footer: footerLayout.data - readonly property int windowMargin: 24 - readonly property int footerButtonHeight: 36 + readonly property int windowMargin: Style.wizardWindowMargin + readonly property int footerButtonHeight: Style.wizardFooterButtonHeight padding: 0 @@ -42,7 +44,7 @@ Pane { anchors.rightMargin: root.windowMargin anchors.topMargin: 0 anchors.bottomMargin: root.windowMargin - spacing: 8 + spacing: Style.wizardFooterSpacing } } } diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 33f212649c267..a5001db825c04 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -81,15 +81,19 @@ QtObject { property int trayModalWidth: 380 property int trayModalHeight: 490 property int trayAccountPopupWidth: variableSize(300) + property int trayAccountActionsMenuWidth: variableSize(340) property int trayAccountPopupRowHeight: variableSize(44) property int trayAccountPopupTopPadding: 4 property int trayAccountPopupActionHeight: variableSize(26) + property int trayAccountPopupPreviewActionHeight: variableSize(52) + property int trayAccountPopupDetailedPreviewActionHeight: variableSize(58) + property int trayAccountPopupCompactSeparatorHeight: variableSize(5) property int trayAccountPopupActionVerticalPadding: 8 property int trayAccountPopupAvatarSize: variableSize(30) property int trayAccountPopupHoverMargin: 5 property int trayAccountPopupAccountHoverVerticalMargin: 4 property int trayAccountPopupHoverRadius: 5 - property int trayAccountPopupRowPadding: 12 + property int trayAccountPopupRowPadding: 12 property int trayAccountPopupRowSpacing: 10 property real trayAccountPopupRowHoverOpacity: 0.07 property int trayAccountPopupHoverAnimationDuration: 80 @@ -113,12 +117,35 @@ QtObject { property int extraExtraSmallSpacing: 1 readonly property int fileProviderSettingsPadding: 12 - property int iconButtonWidth: 36 - property int standardPrimaryButtonHeight: 40 - readonly property int smallIconSize: 16 - readonly property int extraSmallIconSize: 8 - - property int minActivityHeight: variableSize(32) + property int iconButtonWidth: 36 + property int standardPrimaryButtonHeight: 40 + readonly property int smallIconSize: 16 + readonly property int extraSmallIconSize: 8 + + readonly property int wizardWindowMargin: 24 + readonly property int wizardWindowTopMargin: standardSpacing + readonly property int wizardFooterButtonHeight: iconButtonWidth + readonly property int wizardFooterSpacing: trayAccountPopupActionVerticalPadding + readonly property int wizardSectionSpacing: trayAccountPopupRowPadding + readonly property int wizardDialogMaximumWidth: 420 + readonly property int wizardDialogSpacing: wizardSectionSpacing + extraSmallSpacing + readonly property int wizardDialogRadius: wizardSectionSpacing + readonly property int wizardBodyFontPixelSize: pixelSize + extraSmallSpacing + readonly property int wizardHeaderSpacing: trayAccountPopupActionVerticalPadding + readonly property int wizardHeaderRowSpacing: trayAccountPopupRowSpacing + readonly property int wizardHeaderLabelSpacing: extraExtraSmallSpacing + readonly property int wizardHeaderAvatarSize: trayAccountPopupAvatarSize + readonly property int wizardHeaderTitleFontPixelSize: pixelSize + trayAccountPopupActionVerticalPadding + readonly property int wizardHeaderAccountNameFontPixelSize: topLinePixelSize + readonly property int wizardHeaderAccountServerFontPixelSize: subLinePixelSize + readonly property int wizardStandaloneWindowMinimumWidth: 520 + readonly property int wizardStandaloneWindowMinimumHeight: 420 + readonly property int activitiesWindowWidth: 680 + readonly property int activitiesWindowHeight: 700 + readonly property int assistantWindowWidth: 640 + readonly property int assistantWindowHeight: 620 + + property int minActivityHeight: variableSize(32) property int minimumScrollBarWidth: 12 property real minimumScrollBarThumbSize: 0 From 965200c1d080a196ec0757375fd182dabf18430d Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 22 Jun 2026 23:49:44 +0200 Subject: [PATCH 03/20] fix(UI): menu auto resizing and alignment Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 9 +++- src/gui/tray/TrayAccountPopup.qml | 61 +++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 5b5c0610bc65c..efa42f7cc2eff 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -1148,6 +1148,7 @@ - (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex act } __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCNotificationActionsPopup *weakSelf = self; [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]]; for (const auto &actionVariant : actions) { const auto actionData = actionVariant.toMap(); @@ -1170,7 +1171,7 @@ - (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex act action:^{ if (dismisses) { OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); - [weakOwner closeAccountActionsPopup]; + [weakSelf orderOut:nil]; return; } if (opensActivities) { @@ -1392,6 +1393,9 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshActivities:(BOOL)refreshActivities { + const auto preserveTopEdge = [self isVisible]; + const auto topEdge = NSMaxY(self.frame); + _owner = owner; _userIndex = userIndex; [_appsPopup orderOut:nil]; @@ -1561,6 +1565,9 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc NSRect frame = self.frame; frame.size.width = kAccountActionsPopupWidth; frame.size.height = _stack.fittingSize.height; + if (preserveTopEdge) { + frame.origin.y = topEdge - frame.size.height; + } [self setFrame:frame display:NO]; [self invalidateShadow]; diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index c0598a8d750af..114a20ab46cf5 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -255,6 +255,37 @@ Window { closeNotificationActionsMenu() } + function popupSubmenuForRow(menu, row) { + const menuWidth = Math.max(menu.width, menu.implicitWidth) + const menuHeight = Math.max(menu.height, menu.implicitHeight) + const margin = Style.trayAccountPopupHoverMargin + const rowPosition = row.mapToItem(popupContainer, 0, 0) + const screenLeft = root.screen ? root.screen.virtualX : root.x + const screenTop = root.screen ? root.screen.virtualY : root.y + const screenRight = screenLeft + (root.screen ? root.screen.width : root.width) + const screenBottom = screenTop + (root.screen ? root.screen.height : root.height) + const rightAlignedX = row.width + const leftAlignedX = -menuWidth + const rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + menuWidth + const leftAlignedScreenLeft = root.x + rowPosition.x + leftAlignedX + const menuX = rightAlignedScreenRight > screenRight - margin + && leftAlignedScreenLeft >= screenLeft + margin + ? leftAlignedX + : rightAlignedX + + let menuY = 0 + const screenY = root.y + rowPosition.y + const bottomOverflow = screenY + menuHeight - (screenBottom - margin) + if (bottomOverflow > 0) { + menuY -= bottomOverflow + } + if (screenY + menuY < screenTop + margin) { + menuY = screenTop + margin - screenY + } + + menu.popup(row, menuX, menuY) + } + MenuItem { id: userStatusHeader @@ -376,7 +407,7 @@ Window { accountActionsMenu.closeNotificationActionsMenu() TrayAccountAppsModel.setUserId(accountDelegate.userId) if (!appsMenu.opened) { - appsMenu.popup(appsButton, appsButton.width, 0) + accountActionsMenu.popupSubmenuForRow(appsMenu, appsButton) } } @@ -547,7 +578,7 @@ Window { accountActionsMenu.activeNotificationActionsMenu.close() } if (!notificationActionsMenu.opened) { - notificationActionsMenu.popup(notificationRow, notificationRow.width, 0) + accountActionsMenu.popupSubmenuForRow(notificationActionsMenu, notificationRow) } accountActionsMenu.activeNotificationActionsMenu = notificationActionsMenu } @@ -804,12 +835,26 @@ Window { font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: false background: Item {} - contentItem: EnforcedPlainTextLabel { - text: noRecentActivitiesRow.text - font: noRecentActivitiesRow.font - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + Layout.alignment: Qt.AlignVCenter + source: "image://svgimage-custom-color/activity.svg/" + palette.windowText + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: noRecentActivitiesRow.text + font: noRecentActivitiesRow.font + color: palette.windowText + opacity: 0.7 + elide: Text.ElideRight + } } Accessible.role: Accessible.StaticText From 92d57efaf406b3219c9d5e9b376145b3e573317b Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 23 Jun 2026 07:44:35 +0200 Subject: [PATCH 04/20] fix(UI): translations in macOS tray account popup Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 38 +++++++-------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index efa42f7cc2eff..87929b30d248c 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -156,26 +156,6 @@ static QString statusText(OCC::UserStatus::OnlineStatus status) return QCoreApplication::translate("UserStatusSetStatusView", "Online"); } -static QString trayFoldersMenuButtonText(const char *sourceText) -{ - return QCoreApplication::translate("TrayFoldersMenuButton", sourceText); -} - -static QString mainWindowText(const char *sourceText) -{ - return QCoreApplication::translate("MainWindow", sourceText); -} - -static QString trayWindowHeaderText(const char *sourceText) -{ - return QCoreApplication::translate("TrayWindowHeader", sourceText); -} - -static QString trayAccountPopupText(const char *sourceText) -{ - return QCoreApplication::translate("TrayAccountPopup", sourceText); -} - static QString statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) { const auto trimmedMessage = message.trimmed(); @@ -820,7 +800,7 @@ - (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action [self addSubview:label]; auto resolveButton = [[[NCPointingHandButton alloc] init] autorelease]; - resolveButton.title = trayAccountPopupText("Resolve").toNSString(); + resolveButton.title = QCoreApplication::translate("TrayAccountPopup", "Resolve").toNSString(); resolveButton.target = self; resolveButton.action = @selector(resolveButtonClicked:); resolveButton.bezelStyle = NSBezelStyleRounded; @@ -1446,7 +1426,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc const auto appsEnabled = appsModel->rowCount() > 0; const auto assistantEnabled = model->data(userModelIndex, OCC::UserModel::AssistantEnabledRole).toBool(); [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; - [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("User status").toNSString() + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() width:kAccountActionsPopupWidth]]; [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() icon:statusIcon @@ -1458,7 +1438,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]]; [_stack addArrangedSubview:accountActionsSeparator()]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayFoldersMenuButtonText("Open local folder").toNSString() + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ @@ -1467,7 +1447,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]]; if (assistantEnabled) { - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:mainWindowText("Ask Assistant\302\240\342\200\246").toNSString() + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ @@ -1476,7 +1456,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]]; } - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayWindowHeaderText("More apps").toNSString() + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "More apps").toNSString() icon:nil width:kAccountActionsPopupWidth enabled:appsEnabled @@ -1489,7 +1469,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc const auto trayNotifications = model->data(userModelIndex, OCC::UserModel::TrayNotificationsRole).toList(); if (!trayNotifications.isEmpty()) { - [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("Notifications").toNSString() + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Notifications").toNSString() width:kAccountActionsPopupWidth]]; for (const auto &trayNotification : trayNotifications) { const auto notificationData = trayNotification.toMap(); @@ -1524,11 +1504,11 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [_stack addArrangedSubview:compactAccountActionsSeparator()]; } - [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:trayAccountPopupText("Recent activity").toNSString() + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Recent activity").toNSString() width:kAccountActionsPopupWidth]]; const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); if (recentActivities.isEmpty()) { - [_stack addArrangedSubview:[[NCStaticInfoRow alloc] initWithTitle:trayAccountPopupText("No recent activity").toNSString() + [_stack addArrangedSubview:[[NCStaticInfoRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "No recent activity").toNSString() icon:systemSymbolImage(QStringLiteral("clock"), 14.0) width:kAccountActionsPopupWidth]]; } @@ -1550,7 +1530,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]]; } - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:trayAccountPopupText("More activity\342\200\246").toNSString() + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ From 024efe9b57671206cfa8fa58463030f804fd6963 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 23 Jun 2026 11:42:57 +0200 Subject: [PATCH 05/20] fix(UI): tray on windows going to old trayWindow Signed-off-by: Rello --- src/gui/application.cpp | 2 +- src/gui/owncloudgui.cpp | 4 +- src/gui/settingsdialog.cpp | 2 +- src/gui/systray.cpp | 112 ++++++++++--------------------------- src/gui/systray.h | 2 +- 5 files changed, 36 insertions(+), 86 deletions(-) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index 3f508659ba576..f8c1166bd0aaf 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -845,7 +845,7 @@ void Application::slotownCloudWizardDone(int res) Utility::setLaunchOnStartup(_theme->appName(), _theme->appNameGUI(), true); - Systray::instance()->showWindow(); + Systray::instance()->showTrayPopup(); } } diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index bd512234dffd0..4f644e89f6c46 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -226,7 +226,7 @@ void ownCloudGui::slotOpenSettingsDialog() void ownCloudGui::slotOpenMainDialog() { - _tray->showWindow(); + _tray->showActivitiesWindow(); } void ownCloudGui::slotTrayClicked(QSystemTrayIcon::ActivationReason reason) @@ -247,7 +247,7 @@ void ownCloudGui::slotTrayClicked(QSystemTrayIcon::ActivationReason reason) } else if (_tray->isOpen()) { _tray->hideWindow(); } else { - _tray->showWindow(); + _tray->showTrayPopup(); } } // FIXME: Also make sure that any auto updater dialogue https://github.com/owncloud/client/issues/5613 diff --git a/src/gui/settingsdialog.cpp b/src/gui/settingsdialog.cpp index fb66182fab055..59e115ef3208a 100644 --- a/src/gui/settingsdialog.cpp +++ b/src/gui/settingsdialog.cpp @@ -324,7 +324,7 @@ void SettingsDialog::showIssuesList(AccountState *account) const auto userModel = UserModel::instance(); const auto id = userModel->findUserIdForAccount(account); UserModel::instance()->setCurrentUserId(id); - Systray::instance()->showWindow(); + Systray::instance()->showActivitiesWindow(id); } void SettingsDialog::accountAdded(AccountState *s) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index ca5bc7a6b9df4..229d76b6bddda 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -156,7 +156,7 @@ Systray::Systray() #if defined(Q_OS_MACOS) || defined(Q_OS_WIN) connect(AccountManager::instance(), &AccountManager::accountAdded, - this, [this]{ showWindow(); }); + this, [this]{ showTrayPopup(); }); #else // Since the positioning of the QSystemTrayIcon is borked on non-Windows and non-macOS desktop environments, // we hardcode the position of the tray to be in the center when we add a new account from somewhere like @@ -164,7 +164,7 @@ Systray::Systray() // is placed connect(AccountManager::instance(), &AccountManager::accountAdded, - this, [this]{ showWindow(WindowPosition::Center); }); + this, [this]{ showTrayPopup(WindowPosition::Center); }); #endif connect(FolderMan::instance(), &FolderMan::folderListChanged, this, &Systray::slotSyncFoldersChanged); @@ -180,14 +180,6 @@ void Systray::create() _trayEngine->rootContext()->setContextProperty("activityModel", &_fakeActivityModel); } - QQmlComponent trayWindowComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/tray/MainWindow.qml")); - - if(trayWindowComponent.isError()) { - qCWarning(lcSystray) << trayWindowComponent.errorString(); - } else { - _trayWindow.reset(qobject_cast(trayWindowComponent.create())); - } - #ifndef Q_OS_MACOS QQmlComponent popupComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/tray/TrayAccountPopup.qml")); if (popupComponent.isError()) { @@ -204,44 +196,43 @@ void Systray::create() } void Systray::showWindow(WindowPosition position) +{ + Q_UNUSED(position) + + showActivitiesWindow(); +} + +void Systray::showTrayPopup(WindowPosition position) { if (isOpen()) { return; } -#ifdef Q_OS_MACOS - if (!useNormalWindow()) { - showMacOSTrayPopup(geometry()); - setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); - return; - } -#else - if (!useNormalWindow() && _popupWindow) { - positionWindowAtTray(_popupWindow.data()); - _popupWindow->show(); - _popupWindow->raise(); - _popupWindow->requestActivate(); - setIsOpen(true); + if (!isSystemTrayAvailable()) { + showActivitiesWindow(); return; } -#endif - if (!_trayWindow) { +#ifdef Q_OS_MACOS + showMacOSTrayPopup(geometry()); + setIsOpen(true); + UserModel::instance()->fetchCurrentActivityModel(); +#else + if (!_popupWindow) { + showActivitiesWindow(); return; } if (position == WindowPosition::Center) { - positionWindowAtScreenCenter(_trayWindow.data()); + positionWindowAtScreenCenter(_popupWindow.data()); } else { - positionWindowAtTray(_trayWindow.data()); + positionWindowAtTray(_popupWindow.data()); } - _trayWindow->show(); - _trayWindow->raise(); - _trayWindow->requestActivate(); - + _popupWindow->show(); + _popupWindow->raise(); + _popupWindow->requestActivate(); setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); +#endif } void Systray::hideWindow() @@ -251,53 +242,18 @@ void Systray::hideWindow() } #ifdef Q_OS_MACOS - if (!useNormalWindow()) { - hideMacOSTrayPopup(); - if (_trayWindow) { - _trayWindow->hide(); - } - setIsOpen(false); - return; - } + hideMacOSTrayPopup(); #else - if (!useNormalWindow()) { - if (_popupWindow) { - _popupWindow->hide(); - } - if (_trayWindow) { - _trayWindow->hide(); - } - setIsOpen(false); - return; + if (_popupWindow) { + _popupWindow->hide(); } #endif - - if (!_trayWindow) { - return; - } - - _trayWindow->hide(); setIsOpen(false); } void Systray::showQMLWindow() { - if (!_trayWindow) { - return; - } -#ifdef Q_OS_MACOS - hideMacOSTrayPopup(); -#else - if (_popupWindow) { - _popupWindow->hide(); - } -#endif - positionWindowAtTray(_trayWindow.data()); - _trayWindow->show(); - _trayWindow->raise(); - _trayWindow->requestActivate(); - setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); + showActivitiesWindow(); } void Systray::showActivitiesWindow(int userIndex) @@ -544,7 +500,7 @@ void Systray::setupContextMenu() if (AccountManager::instance()->accounts().isEmpty()) { _contextMenu->addAction(tr("Add account"), this, &Systray::openAccountWizard); } else { - _contextMenu->addAction(tr("Open %1 Desktop", "Open Nextcloud main window. Placeholer will be the application name. Please keep it.").arg(APPLICATION_NAME), this, [this]{ showWindow(); }); + _contextMenu->addAction(tr("Open %1 Desktop", "Open Nextcloud main window. Placeholer will be the application name. Please keep it.").arg(APPLICATION_NAME), this, [this]{ showActivitiesWindow(); }); } auto pauseAction = _contextMenu->addAction(tr("Pause sync"), this, &Systray::slotPauseAllFolders); @@ -897,14 +853,8 @@ void Systray::createFileActionsDialogWithAccountState(const QString &localPath, void Systray::presentShareViewInTray(const QString &localPath) { - const auto folder = FolderMan::instance()->folderForPath(localPath); - if (!folder) { - qCWarning(lcSystray) << "Could not open file details view in tray for" << localPath << "no responsible folder found"; - return; - } - qCDebug(lcSystray) << "Opening file details view in tray for " << localPath; - - Q_EMIT showFileDetails(folder->accountState(), localPath, FileDetailsPage::Sharing); + qCDebug(lcSystray) << "Opening file details dialog for " << localPath; + createShareDialog(localPath); } void Systray::presentFileActionsViewInSystray(const QString &localPath) diff --git a/src/gui/systray.h b/src/gui/systray.h index ffcb4fd44fc9e..087ed4eba9fea 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -147,6 +147,7 @@ public slots: void destroyDialog(QQuickWindow *window) const; void showWindow(OCC::Systray::WindowPosition position = OCC::Systray::WindowPosition::Default); + void showTrayPopup(OCC::Systray::WindowPosition position = OCC::Systray::WindowPosition::Default); void hideWindow(); void showQMLWindow(); void showActivitiesWindow(int userIndex = -1); @@ -216,7 +217,6 @@ private slots: std::unique_ptr _trayEngine; QPointer _contextMenu; - QSharedPointer _trayWindow; QHash> _activitiesWindows; QHash> _assistantWindows; QPointer _userStatusWindow; From 2a9c98b20abe6cf082bcf4d5ae31bee8171766f3 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 23 Jun 2026 16:02:06 +0200 Subject: [PATCH 06/20] fix(UI): fix test issues Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 114a20ab46cf5..6f298fd7bb2f9 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -497,7 +497,7 @@ Window { appsMenu.close() accountActionsMenu.close() Systray.hideWindow() - TrayAccountAppsModel.openAppUrl(appUrl) + TrayAccountAppsModel.openAppUrl(model.appUrl) } Accessible.role: Accessible.MenuItem @@ -780,7 +780,7 @@ Window { EnforcedPlainTextLabel { Layout.fillWidth: true text: recentActivityRow.text - font: recentActivityRow.font + font.pixelSize: recentActivityRow.font.pixelSize font.weight: Font.DemiBold color: palette.windowText elide: Text.ElideRight @@ -1039,6 +1039,7 @@ Window { Accessible.name: accountDelegate.accountAlertTitle Accessible.onPressAction: accountAlertBox.clicked() } + } } Rectangle { From 607d67706e5741e95ac90b0d459ba546ddf4ee54 Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 24 Jun 2026 15:03:52 +0200 Subject: [PATCH 07/20] fix(UI): real QML menus on win Signed-off-by: Rello --- src/gui/tray/AutoSizingMenu.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/tray/AutoSizingMenu.qml b/src/gui/tray/AutoSizingMenu.qml index c95355dac5c98..e3cc79871d459 100644 --- a/src/gui/tray/AutoSizingMenu.qml +++ b/src/gui/tray/AutoSizingMenu.qml @@ -8,6 +8,8 @@ import QtQuick.Controls import Style Menu { + popupType: Popup.Window + width: { var result = 0; var padding = 0; From a0d9e394d49cb7c8104b4c8c10fdd691c222a13d Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 24 Jun 2026 19:18:44 +0200 Subject: [PATCH 08/20] fix(UI): tray on windows alignment Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 180 ++++++++++++++++-------------- 1 file changed, 94 insertions(+), 86 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 6f298fd7bb2f9..8fb7120ec7109 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -99,7 +99,8 @@ Window { readonly property int userId: model.id readonly property int onlineStatus: model.status readonly property bool onlineStatusEnabled: model.isConnected && model.serverHasUserStatus - readonly property string statusIcon: model.statusIcon + readonly property url statusIcon: model.statusIcon + readonly property bool hasStatusIcon: statusIcon.toString() !== "" readonly property string statusMessage: model.statusMessage readonly property var recentActivities: model.recentActivities ? model.recentActivities : [] readonly property var trayNotifications: model.trayNotifications ? model.trayNotifications : [] @@ -162,24 +163,7 @@ Window { TrayAccountAppsModel.setUserId(accountDelegate.userId) UserModel.fetchActivityPreview(accountDelegate.userId) root.closeActiveAccountActionsMenu() - - const rightAlignedX = Math.max(Style.trayAccountPopupHoverMargin, - accountRow.width - accountActionsMenu.width - Style.trayAccountPopupHoverMargin) - const leftAlignedX = Style.trayAccountPopupHoverMargin - const rowPosition = accountRow.mapToItem(popupContainer, 0, 0) - const screenLeft = root.screen ? root.screen.virtualX : root.x - const screenWidth = root.screen ? root.screen.width : root.width - const screenRight = screenLeft + screenWidth - const rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + accountActionsMenu.width - - const menuX = rightAlignedScreenRight > screenRight - Style.trayAccountPopupHoverMargin - && root.x + rowPosition.x + leftAlignedX >= screenLeft + Style.trayAccountPopupHoverMargin - ? leftAlignedX - : rightAlignedX - - accountActionsMenu.popup(accountRow, - menuX, - Style.trayAccountPopupAccountHoverVerticalMargin) + accountActionsMenu.popupSubmenuForRow(accountActionsMenu, accountRow) root.activeAccountActionsMenu = accountActionsMenu } @@ -266,12 +250,19 @@ Window { const screenBottom = screenTop + (root.screen ? root.screen.height : root.height) const rightAlignedX = row.width const leftAlignedX = -menuWidth - const rightAlignedScreenRight = root.x + rowPosition.x + rightAlignedX + menuWidth - const leftAlignedScreenLeft = root.x + rowPosition.x + leftAlignedX - const menuX = rightAlignedScreenRight > screenRight - margin + const rowScreenX = root.x + rowPosition.x + const rightAlignedScreenRight = rowScreenX + rightAlignedX + menuWidth + const leftAlignedScreenLeft = rowScreenX + leftAlignedX + let menuX = rightAlignedScreenRight > screenRight - margin && leftAlignedScreenLeft >= screenLeft + margin ? leftAlignedX : rightAlignedX + const menuScreenLeft = rowScreenX + menuX + if (menuScreenLeft < screenLeft + margin) { + menuX = screenLeft + margin - rowScreenX + } else if (menuScreenLeft + menuWidth > screenRight - margin) { + menuX = screenRight - margin - menuWidth - rowScreenX + } let menuY = 0 const screenY = root.y + rowPosition.y @@ -310,10 +301,12 @@ Window { MenuItem { id: statusButton - enabled: accountDelegate.onlineStatusEnabled + readonly property bool isActionable: accountDelegate.onlineStatusEnabled + + enabled: true text: accountDelegate.currentStatusLabelText() font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true + hoverEnabled: isActionable onHoveredChanged: { if (hovered) { accountActionsMenu.closeSubmenus() @@ -325,8 +318,8 @@ Window { Image { Layout.preferredWidth: Style.trayAccountPopupSyncIconSize Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - visible: statusButton.enabled - source: statusButton.enabled ? accountDelegate.statusIcon : "" + visible: accountDelegate.hasStatusIcon + source: accountDelegate.statusIcon sourceSize.width: Style.trayAccountPopupSyncIconSize sourceSize.height: Style.trayAccountPopupSyncIconSize cache: false @@ -336,18 +329,25 @@ Window { Layout.fillWidth: true text: statusButton.text font: statusButton.font - color: statusButton.enabled ? palette.windowText : palette.mid + color: palette.windowText elide: Text.ElideRight } } onClicked: { + if (!isActionable) { + return + } root._closing = true Systray.showUserStatusWindow(accountDelegate.userId) } - Accessible.role: Accessible.Button + Accessible.role: statusButton.isActionable ? Accessible.Button : Accessible.StaticText Accessible.name: text - Accessible.onPressAction: statusButton.clicked() + Accessible.onPressAction: { + if (statusButton.isActionable) { + statusButton.clicked() + } + } } MenuSeparator { @@ -450,63 +450,6 @@ Window { Accessible.onPressAction: appsButton.clicked() } - AutoSizingMenu { - id: appsMenu - - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - - Repeater { - model: TrayAccountAppsModel - - delegate: MenuItem { - id: appEntry - - text: model.appName - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - - function appIconSource() { - if (!model.appIconUrl || model.appIconUrl === "") { - return "" - } - return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText - } - - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - source: appEntry.appIconSource() - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - fillMode: Image.PreserveAspectFit - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: appEntry.text - font: appEntry.font - color: palette.windowText - elide: Text.ElideRight - } - } - - onTriggered: { - root._closing = true - appsMenu.close() - accountActionsMenu.close() - Systray.hideWindow() - TrayAccountAppsModel.openAppUrl(model.appUrl) - } - - Accessible.role: Accessible.MenuItem - Accessible.name: qsTr("Open %1 in browser").arg(model.appName) - Accessible.onPressAction: appEntry.triggered() - } - } - } - MenuSeparator { } @@ -880,6 +823,71 @@ Window { } } + AutoSizingMenu { + id: appsMenu + + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + + Repeater { + model: TrayAccountAppsModel + + delegate: MenuItem { + id: appEntry + + text: model.appName + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + + function appIconSource() { + if (!model.appIconUrl || model.appIconUrl === "") { + return "" + } + return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText + } + + background: Rectangle { + visible: opacity > 0 + color: root.rowHoverColor + opacity: appEntry.hovered ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + } + + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + source: appEntry.appIconSource() + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + fillMode: Image.PreserveAspectFit + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: appEntry.text + font: appEntry.font + color: palette.windowText + elide: Text.ElideRight + } + } + + onTriggered: { + root._closing = true + appsMenu.close() + accountActionsMenu.close() + Systray.hideWindow() + TrayAccountAppsModel.openAppUrl(model.appUrl) + } + + Accessible.role: Accessible.MenuItem + Accessible.name: qsTr("Open %1 in browser").arg(model.appName) + Accessible.onPressAction: appEntry.triggered() + } + } + } + contentItem: RowLayout { spacing: Style.trayAccountPopupRowSpacing From bed4d7520c562e5c45d111a27fde7f566708cd3e Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 24 Jun 2026 21:33:25 +0200 Subject: [PATCH 09/20] fix(UI): tray on windows alignment Signed-off-by: Rello --- src/gui/systray.cpp | 5 +- src/gui/tray/TrayAccountPopup.qml | 174 ++++++++++++++++-------------- theme/Style/Style.qml | 2 + 3 files changed, 96 insertions(+), 85 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 229d76b6bddda..4957fe3cdd17b 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -1062,10 +1062,11 @@ void Systray::positionWindowAtTray(QQuickWindow *window) const // otherwise it is being incorrectly resized by the OS or Qt when switching to a screen // with a different DPI setting const auto initialSize = window->size(); - const auto position = computeWindowPosition(initialSize.width(), initialSize.height()); - window->setPosition(position); window->setScreen(currentScreen()); window->resize(initialSize); + + const auto position = computeWindowPosition(initialSize.width(), initialSize.height()); + window->setPosition(position); } void Systray::positionWindowAtScreenCenter(QQuickWindow *window) const diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 8fb7120ec7109..5738b5c1d1fa9 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -6,6 +6,7 @@ import QtQuick import QtQuick.Controls.Basic import QtQuick.Layouts +import QtQml.Models import Qt5Compat.GraphicalEffects import Style @@ -22,15 +23,14 @@ Window { property var activeAccountActionsMenu: null readonly property bool hasAccounts: UserModel && UserModel.count > 0 - readonly property color rowHoverColor: Style.darkMode - ? Qt.rgba(1, 1, 1, Style.trayAccountPopupRowHoverOpacity) - : Qt.rgba(0, 0, 0, Style.trayAccountPopupRowHoverOpacity) width: Style.trayAccountPopupWidth height: contentColumn.height color: "transparent" flags: Qt.Tool | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint + Component.onCompleted: Systray.forceWindowInit(root) + onVisibleChanged: { if (visible) { _hadFocusSinceShow = false @@ -63,6 +63,29 @@ Window { return qsTranslate("MainWindow", "Ask Assistant\u00A0…") } + component TrayActionHoverBackground: Item { + property bool active: false + property int verticalMargin: 0 + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.leftMargin: Style.trayAccountPopupHoverMargin + anchors.rightMargin: Style.trayAccountPopupHoverMargin + anchors.topMargin: parent.verticalMargin + anchors.bottomMargin: parent.verticalMargin + radius: Style.trayAccountPopupHoverRadius + visible: opacity > 0 + color: Style.darkMode + ? Qt.rgba(1, 1, 1, Style.trayAccountPopupRowHoverOpacity) + : Qt.rgba(0, 0, 0, Style.trayAccountPopupRowHoverOpacity) + opacity: parent.active ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + } + } + Rectangle { id: popupContainer anchors.fill: parent @@ -187,22 +210,9 @@ Window { } } - background: Item { - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: Style.trayAccountPopupHoverMargin - anchors.rightMargin: Style.trayAccountPopupHoverMargin - anchors.topMargin: Style.trayAccountPopupAccountHoverVerticalMargin - anchors.bottomMargin: Style.trayAccountPopupAccountHoverVerticalMargin - radius: Style.trayAccountPopupHoverRadius - visible: opacity > 0 - color: root.rowHoverColor - opacity: accountRow.hovered || accountActionsMenu.opened ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } + background: TrayActionHoverBackground { + active: accountRow.hovered || accountActionsMenu.opened + verticalMargin: Style.trayAccountPopupAccountHoverVerticalMargin } AutoSizingMenu { @@ -239,6 +249,20 @@ Window { closeNotificationActionsMenu() } + function itemIndex(item) { + for (let i = 0; i < count; ++i) { + if (itemAt(i) === item) { + return i + } + } + return -1 + } + + function insertionIndexAfter(anchorItem, offset) { + const anchorIndex = itemIndex(anchorItem) + return anchorIndex < 0 ? count : anchorIndex + 1 + offset + } + function popupSubmenuForRow(menu, row) { const menuWidth = Math.max(menu.width, menu.implicitWidth) const menuHeight = Math.max(menu.height, menu.implicitHeight) @@ -312,6 +336,9 @@ Window { accountActionsMenu.closeSubmenus() } } + background: TrayActionHoverBackground { + active: statusButton.isActionable && statusButton.hovered + } contentItem: RowLayout { spacing: 8 @@ -364,6 +391,9 @@ Window { accountActionsMenu.closeSubmenus() } } + background: TrayActionHoverBackground { + active: openLocalFolderButton.hovered + } onClicked: accountDelegate.openLocalFolder() Accessible.role: Accessible.Button @@ -385,6 +415,9 @@ Window { accountActionsMenu.closeSubmenus() } } + background: TrayActionHoverBackground { + active: assistantButton.hovered + } onClicked: accountDelegate.openAssistant() Accessible.role: Accessible.Button @@ -419,11 +452,8 @@ Window { onClicked: openAppsMenu() - background: Rectangle { - visible: opacity > 0 - color: root.rowHoverColor - opacity: appsButton.hovered || appsMenu.opened ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + background: TrayActionHoverBackground { + active: appsButton.hovered || appsMenu.opened } contentItem: RowLayout { @@ -476,9 +506,16 @@ Window { Accessible.name: text } - Repeater { + Instantiator { model: accountDelegate.trayNotifications + onObjectAdded: (index, object) => { + accountActionsMenu.insertItem(accountActionsMenu.insertionIndexAfter(notificationsHeader, index), object) + } + onObjectRemoved: (index, object) => { + accountActionsMenu.removeItem(object) + } + delegate: MenuItem { id: notificationRow @@ -539,11 +576,8 @@ Window { onClicked: openNotification() - background: Rectangle { - visible: opacity > 0 - color: root.rowHoverColor - opacity: notificationRow.hovered || notificationActionsMenu.opened ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + background: TrayActionHoverBackground { + active: notificationRow.hovered || notificationActionsMenu.opened } contentItem: RowLayout { @@ -598,6 +632,7 @@ Window { AutoSizingMenu { id: notificationActionsMenu + width: Style.trayNotificationActionsMenuWidth closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onClosed: { if (accountActionsMenu.activeNotificationActionsMenu === notificationActionsMenu) { @@ -615,6 +650,10 @@ Window { text: modelData.label font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + background: TrayActionHoverBackground { + active: notificationActionMenuItem.hovered + } onTriggered: { const activityIndex = notificationRow.modelData.activityIndex const actionIndex = modelData.actionIndex @@ -672,9 +711,16 @@ Window { Accessible.name: text } - Repeater { + Instantiator { model: accountDelegate.recentActivities + onObjectAdded: (index, object) => { + accountActionsMenu.insertItem(accountActionsMenu.insertionIndexAfter(lastActivitiesHeader, index), object) + } + onObjectRemoved: (index, object) => { + accountActionsMenu.removeItem(object) + } + delegate: MenuItem { id: recentActivityRow @@ -689,11 +735,8 @@ Window { : Style.trayAccountPopupPreviewActionHeight font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: true - background: Rectangle { - visible: opacity > 0 - color: root.rowHoverColor - opacity: recentActivityRow.hovered ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + background: TrayActionHoverBackground { + active: recentActivityRow.hovered } function iconSource() { @@ -815,6 +858,9 @@ Window { accountActionsMenu.closeSubmenus() } } + background: TrayActionHoverBackground { + active: moreActivitiesButton.hovered + } onClicked: accountDelegate.openActivities() Accessible.role: Accessible.Button @@ -826,6 +872,7 @@ Window { AutoSizingMenu { id: appsMenu + width: Style.trayAccountAppsMenuWidth closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape Repeater { @@ -845,11 +892,8 @@ Window { return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText } - background: Rectangle { - visible: opacity > 0 - color: root.rowHoverColor - opacity: appEntry.hovered ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } + background: TrayActionHoverBackground { + active: appEntry.hovered } contentItem: RowLayout { @@ -1076,20 +1120,8 @@ Window { padding: 0 leftPadding: Style.trayAccountPopupRowPadding - background: Item { - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: Style.trayAccountPopupHoverMargin - anchors.rightMargin: Style.trayAccountPopupHoverMargin - radius: Style.trayAccountPopupHoverRadius - visible: opacity > 0 - color: root.rowHoverColor - opacity: addAccountRow.hovered ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } + background: TrayActionHoverBackground { + active: addAccountRow.hovered } contentItem: EnforcedPlainTextLabel { @@ -1124,20 +1156,8 @@ Window { padding: 0 leftPadding: Style.trayAccountPopupRowPadding - background: Item { - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: Style.trayAccountPopupHoverMargin - anchors.rightMargin: Style.trayAccountPopupHoverMargin - radius: Style.trayAccountPopupHoverRadius - visible: opacity > 0 - color: root.rowHoverColor - opacity: settingsRow.hovered ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } + background: TrayActionHoverBackground { + active: settingsRow.hovered } contentItem: EnforcedPlainTextLabel { @@ -1172,20 +1192,8 @@ Window { padding: 0 leftPadding: Style.trayAccountPopupRowPadding - background: Item { - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: Style.trayAccountPopupHoverMargin - anchors.rightMargin: Style.trayAccountPopupHoverMargin - radius: Style.trayAccountPopupHoverRadius - visible: opacity > 0 - color: root.rowHoverColor - opacity: quitRow.hovered ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } + background: TrayActionHoverBackground { + active: quitRow.hovered } contentItem: EnforcedPlainTextLabel { diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index a5001db825c04..b333e037b7c09 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -82,6 +82,8 @@ QtObject { property int trayModalHeight: 490 property int trayAccountPopupWidth: variableSize(300) property int trayAccountActionsMenuWidth: variableSize(340) + property int trayAccountAppsMenuWidth: variableSize(220) + property int trayNotificationActionsMenuWidth: variableSize(160) property int trayAccountPopupRowHeight: variableSize(44) property int trayAccountPopupTopPadding: 4 property int trayAccountPopupActionHeight: variableSize(26) From be312d5075c9d448213874353045470e5f1122f5 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 25 Jun 2026 08:50:31 +0200 Subject: [PATCH 10/20] fix(UI): test fixes and update Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 46 ++++++++++++++++++++++++++----- src/gui/tray/usermodel.cpp | 5 ++++ src/gui/tray/usermodel.h | 3 +- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 5738b5c1d1fa9..a96e3e14e4d21 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -219,13 +219,19 @@ Window { id: accountActionsMenu property var activeNotificationActionsMenu: null + property var anchorRow: null width: Style.trayAccountActionsMenuWidth + topPadding: Style.trayAccountPopupActionVerticalPadding + bottomPadding: Style.trayAccountPopupActionVerticalPadding + focus: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape height: implicitHeight + onHeightChanged: repositionOpenSubmenu(accountActionsMenu) onClosed: { appsMenu.close() closeNotificationActionsMenu() + anchorRow = null if (root.activeAccountActionsMenu === accountActionsMenu) { root.activeAccountActionsMenu = null } @@ -263,7 +269,7 @@ Window { return anchorIndex < 0 ? count : anchorIndex + 1 + offset } - function popupSubmenuForRow(menu, row) { + function positionSubmenuForRow(menu, row) { const menuWidth = Math.max(menu.width, menu.implicitWidth) const menuHeight = Math.max(menu.height, menu.implicitHeight) const margin = Style.trayAccountPopupHoverMargin @@ -298,7 +304,20 @@ Window { menuY = screenTop + margin - screenY } - menu.popup(row, menuX, menuY) + menu.x = menuX + menu.y = menuY + } + + function popupSubmenuForRow(menu, row) { + menu.anchorRow = row + positionSubmenuForRow(menu, row) + menu.popup(row, menu.x, menu.y) + } + + function repositionOpenSubmenu(menu) { + if (menu.opened && menu.anchorRow) { + positionSubmenuForRow(menu, menu.anchorRow) + } } MenuItem { @@ -327,17 +346,17 @@ Window { readonly property bool isActionable: accountDelegate.onlineStatusEnabled - enabled: true + enabled: statusButton.isActionable text: accountDelegate.currentStatusLabelText() font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: isActionable + hoverEnabled: statusButton.enabled onHoveredChanged: { if (hovered) { accountActionsMenu.closeSubmenus() } } background: TrayActionHoverBackground { - active: statusButton.isActionable && statusButton.hovered + active: statusButton.enabled && statusButton.hovered } contentItem: RowLayout { spacing: 8 @@ -356,12 +375,12 @@ Window { Layout.fillWidth: true text: statusButton.text font: statusButton.font - color: palette.windowText + color: statusButton.enabled ? palette.windowText : palette.mid elide: Text.ElideRight } } onClicked: { - if (!isActionable) { + if (!statusButton.enabled) { return } root._closing = true @@ -632,9 +651,16 @@ Window { AutoSizingMenu { id: notificationActionsMenu + property var anchorRow: null + width: Style.trayNotificationActionsMenuWidth + topPadding: Style.trayAccountPopupActionVerticalPadding + bottomPadding: Style.trayAccountPopupActionVerticalPadding + focus: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + onHeightChanged: accountActionsMenu.repositionOpenSubmenu(notificationActionsMenu) onClosed: { + anchorRow = null if (accountActionsMenu.activeNotificationActionsMenu === notificationActionsMenu) { accountActionsMenu.activeNotificationActionsMenu = null } @@ -872,8 +898,14 @@ Window { AutoSizingMenu { id: appsMenu + property var anchorRow: null + width: Style.trayAccountAppsMenuWidth + topPadding: Style.trayAccountPopupActionVerticalPadding + bottomPadding: Style.trayAccountPopupActionVerticalPadding + focus: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) Repeater { model: TrayAccountAppsModel diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 9d9cdc7826d93..ee217fc068fed 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -528,6 +528,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerTextColorChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::accentColorChanged); + connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::serverHasUserStatusChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::assistantStateChanged); connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders); @@ -2620,6 +2621,10 @@ void UserModel::addUser(AccountStatePtr &user, const bool &isCurrent) }); connect(u, &User::accountStateChanged, this, &UserModel::updateSyncErrorUsers); + connect(u, &User::serverHasUserStatusChanged, this, [this, row] { + emit dataChanged(index(row, 0), index(row, 0), { UserModel::ServerHasUserStatusRole }); + }); + connect(u, &User::syncStatusChanged, this, [this, row] { emit dataChanged(index(row, 0), index(row, 0), { UserModel::SyncStatusIconRole, UserModel::SyncStatusOkRole }); diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index a1d8bd0df76a5..dfbec20efbe4f 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -62,7 +62,7 @@ class User : public QObject Q_PROPERTY(QColor headerColor READ headerColor NOTIFY headerColorChanged) Q_PROPERTY(QColor headerTextColor READ headerTextColor NOTIFY headerTextColorChanged) Q_PROPERTY(QColor accentColor READ accentColor NOTIFY accentColorChanged) - Q_PROPERTY(bool serverHasUserStatus READ serverHasUserStatus CONSTANT) + Q_PROPERTY(bool serverHasUserStatus READ serverHasUserStatus NOTIFY serverHasUserStatusChanged) Q_PROPERTY(UserStatus::OnlineStatus status READ status NOTIFY statusChanged) Q_PROPERTY(QUrl statusIcon READ statusIcon NOTIFY statusChanged) Q_PROPERTY(QString statusEmoji READ statusEmoji NOTIFY statusChanged) @@ -165,6 +165,7 @@ class User : public QObject void trayNotificationsChanged(); void accountAlertChanged(); void accountStateChanged(); + void serverHasUserStatusChanged(); void statusChanged(); void desktopNotificationsAllowedChanged(); void headerColorChanged(); From 9b38d3d1b76bfd179440882e8a17a1d49f72bbab Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 25 Jun 2026 12:16:36 +0200 Subject: [PATCH 11/20] fix(UI): test fixes and update Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index a96e3e14e4d21..c882e56583b8c 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -225,6 +225,7 @@ Window { topPadding: Style.trayAccountPopupActionVerticalPadding bottomPadding: Style.trayAccountPopupActionVerticalPadding focus: false + modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape height: implicitHeight onHeightChanged: repositionOpenSubmenu(accountActionsMenu) @@ -269,7 +270,7 @@ Window { return anchorIndex < 0 ? count : anchorIndex + 1 + offset } - function positionSubmenuForRow(menu, row) { + function submenuPositionForRow(menu, row) { const menuWidth = Math.max(menu.width, menu.implicitWidth) const menuHeight = Math.max(menu.height, menu.implicitHeight) const margin = Style.trayAccountPopupHoverMargin @@ -304,19 +305,19 @@ Window { menuY = screenTop + margin - screenY } - menu.x = menuX - menu.y = menuY + return { "x": menuX, "y": menuY } } function popupSubmenuForRow(menu, row) { menu.anchorRow = row - positionSubmenuForRow(menu, row) - menu.popup(row, menu.x, menu.y) + const position = submenuPositionForRow(menu, row) + menu.popup(row, position.x, position.y) } function repositionOpenSubmenu(menu) { if (menu.opened && menu.anchorRow) { - positionSubmenuForRow(menu, menu.anchorRow) + const position = submenuPositionForRow(menu, menu.anchorRow) + menu.popup(menu.anchorRow, position.x, position.y) } } @@ -657,6 +658,7 @@ Window { topPadding: Style.trayAccountPopupActionVerticalPadding bottomPadding: Style.trayAccountPopupActionVerticalPadding focus: false + modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onHeightChanged: accountActionsMenu.repositionOpenSubmenu(notificationActionsMenu) onClosed: { @@ -904,6 +906,7 @@ Window { topPadding: Style.trayAccountPopupActionVerticalPadding bottomPadding: Style.trayAccountPopupActionVerticalPadding focus: false + modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) From c9a3f7b963535854f046c0e66e5317ee573b89de Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 25 Jun 2026 13:54:34 +0200 Subject: [PATCH 12/20] fix(UI): test fixes and update Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 68 ++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index c882e56583b8c..3be29de769f66 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -380,7 +380,7 @@ Window { elide: Text.ElideRight } } - onClicked: { + onTriggered: { if (!statusButton.enabled) { return } @@ -392,7 +392,7 @@ Window { Accessible.name: text Accessible.onPressAction: { if (statusButton.isActionable) { - statusButton.clicked() + statusButton.triggered() } } } @@ -414,11 +414,11 @@ Window { background: TrayActionHoverBackground { active: openLocalFolderButton.hovered } - onClicked: accountDelegate.openLocalFolder() + onTriggered: accountDelegate.openLocalFolder() Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: openLocalFolderButton.clicked() + Accessible.onPressAction: openLocalFolderButton.triggered() } MenuItem { @@ -438,11 +438,11 @@ Window { background: TrayActionHoverBackground { active: assistantButton.hovered } - onClicked: accountDelegate.openAssistant() + onTriggered: accountDelegate.openAssistant() Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: assistantButton.clicked() + Accessible.onPressAction: assistantButton.triggered() } MenuItem { @@ -457,6 +457,7 @@ Window { if (!enabled) { return } + appsMenu.cancelHoverLeaveClose() accountActionsMenu.closeNotificationActionsMenu() TrayAccountAppsModel.setUserId(accountDelegate.userId) if (!appsMenu.opened) { @@ -467,10 +468,12 @@ Window { onHoveredChanged: { if (hovered) { openAppsMenu() + } else { + appsMenu.scheduleHoverLeaveClose() } } - onClicked: openAppsMenu() + onTriggered: openAppsMenu() background: TrayActionHoverBackground { active: appsButton.hovered || appsMenu.opened @@ -497,7 +500,7 @@ Window { Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: appsButton.clicked() + Accessible.onPressAction: appsButton.triggered() } MenuSeparator { @@ -594,7 +597,7 @@ Window { } } - onClicked: openNotification() + onTriggered: openNotification() background: TrayActionHoverBackground { active: notificationRow.hovered || notificationActionsMenu.opened @@ -709,7 +712,7 @@ Window { Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: notificationRow.clicked() + Accessible.onPressAction: notificationRow.triggered() } } @@ -831,11 +834,11 @@ Window { } } - onClicked: accountDelegate.openActivities() + onTriggered: accountDelegate.openActivities() Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: recentActivityRow.clicked() + Accessible.onPressAction: recentActivityRow.triggered() } } @@ -889,11 +892,11 @@ Window { background: TrayActionHoverBackground { active: moreActivitiesButton.hovered } - onClicked: accountDelegate.openActivities() + onTriggered: accountDelegate.openActivities() Accessible.role: Accessible.Button Accessible.name: text - Accessible.onPressAction: moreActivitiesButton.clicked() + Accessible.onPressAction: moreActivitiesButton.triggered() } } @@ -901,6 +904,7 @@ Window { id: appsMenu property var anchorRow: null + property var hoveredAppEntry: null width: Style.trayAccountAppsMenuWidth topPadding: Style.trayAccountPopupActionVerticalPadding @@ -909,6 +913,33 @@ Window { modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) + onClosed: { + anchorRow = null + hoveredAppEntry = null + hoverLeaveCloseTimer.stop() + } + + function cancelHoverLeaveClose() { + hoverLeaveCloseTimer.stop() + } + + function scheduleHoverLeaveClose() { + if (opened && !appsButton.hovered && !hoveredAppEntry) { + hoverLeaveCloseTimer.restart() + } + } + + Timer { + id: hoverLeaveCloseTimer + + interval: 120 + repeat: false + onTriggered: { + if (appsMenu.opened && !appsButton.hovered && !appsMenu.hoveredAppEntry) { + appsMenu.close() + } + } + } Repeater { model: TrayAccountAppsModel @@ -919,6 +950,15 @@ Window { text: model.appName font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: true + onHoveredChanged: { + if (hovered) { + appsMenu.hoveredAppEntry = appEntry + appsMenu.cancelHoverLeaveClose() + } else if (appsMenu.hoveredAppEntry === appEntry) { + appsMenu.hoveredAppEntry = null + appsMenu.scheduleHoverLeaveClose() + } + } function appIconSource() { if (!model.appIconUrl || model.appIconUrl === "") { From 8b053ad103ce7526cbf18c64f81c071a429c1e74 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 25 Jun 2026 16:39:46 +0200 Subject: [PATCH 13/20] fix(UI): test fixes and update Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 35 +++--- src/gui/tray/TrayAccountPopup.qml | 171 +++++++++++++++++++------- 2 files changed, 145 insertions(+), 61 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 87929b30d248c..94c5c17e40011 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -1415,29 +1415,32 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc }); const auto userModelIndex = model->index(userIndex); + const auto serverHasUserStatus = model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() - && model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); - const auto status = model->data(userModelIndex, OCC::UserModel::StatusRole).value(); - const auto statusMessage = model->data(userModelIndex, OCC::UserModel::StatusMessageRole).toString(); - NSImage *statusIcon = nsImageFromQUrl(model->data(userModelIndex, OCC::UserModel::StatusIconRole).toUrl()); + && serverHasUserStatus; auto appsModel = OCC::TrayAccountAppsModel::instance(); appsModel->setUserId(userIndex); const auto appsEnabled = appsModel->rowCount() > 0; const auto assistantEnabled = model->data(userModelIndex, OCC::UserModel::AssistantEnabledRole).toBool(); [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; - [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() - width:kAccountActionsPopupWidth]]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() - icon:statusIcon - width:kAccountActionsPopupWidth - enabled:onlineStatusEnabled - action:^{ - [weakOwner openOnlineStatusForIndex:userIndex]; - } hoverAction:^(NSView *) { - [weakSelf hideAppsPopup]; - }]]; - [_stack addArrangedSubview:accountActionsSeparator()]; + if (serverHasUserStatus) { + const auto status = model->data(userModelIndex, OCC::UserModel::StatusRole).value(); + const auto statusMessage = model->data(userModelIndex, OCC::UserModel::StatusMessageRole).toString(); + NSImage *statusIcon = nsImageFromQUrl(model->data(userModelIndex, OCC::UserModel::StatusIconRole).toUrl()); + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() + width:kAccountActionsPopupWidth]]; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() + icon:statusIcon + width:kAccountActionsPopupWidth + enabled:onlineStatusEnabled + action:^{ + [weakOwner openOnlineStatusForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + [_stack addArrangedSubview:accountActionsSeparator()]; + } [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder").toNSString() width:kAccountActionsPopupWidth enabled:YES diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 3be29de769f66..10d2cd2410c05 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -121,6 +121,7 @@ Window { id: accountDelegate readonly property int userId: model.id readonly property int onlineStatus: model.status + readonly property bool hasUserStatusSection: model.serverHasUserStatus readonly property bool onlineStatusEnabled: model.isConnected && model.serverHasUserStatus readonly property url statusIcon: model.statusIcon readonly property bool hasStatusIcon: statusIcon.toString() !== "" @@ -219,6 +220,8 @@ Window { id: accountActionsMenu property var activeNotificationActionsMenu: null + property var activeSubmenu: null + property var activeSubmenuAnchorRow: null property var anchorRow: null width: Style.trayAccountActionsMenuWidth @@ -230,22 +233,76 @@ Window { height: implicitHeight onHeightChanged: repositionOpenSubmenu(accountActionsMenu) onClosed: { - appsMenu.close() - closeNotificationActionsMenu() + closeSubmenus() anchorRow = null if (root.activeAccountActionsMenu === accountActionsMenu) { root.activeAccountActionsMenu = null } } + Timer { + id: activeSubmenuCloseTimer + + interval: 180 + repeat: false + onTriggered: accountActionsMenu.closeActiveSubmenuIfPointerLeft() + } + + function activeSubmenuContainsPointer() { + return !!activeSubmenu && activeSubmenu.containsMouse === true + } + + function cancelActiveSubmenuClose() { + activeSubmenuCloseTimer.stop() + } + + function clearActiveSubmenu(menu) { + if (activeSubmenu !== menu) { + return + } + activeSubmenuCloseTimer.stop() + activeSubmenu = null + activeSubmenuAnchorRow = null + if (activeNotificationActionsMenu === menu) { + activeNotificationActionsMenu = null + } + } + + function closeActiveSubmenu() { + const submenu = activeSubmenu + activeSubmenuCloseTimer.stop() + activeSubmenu = null + activeSubmenuAnchorRow = null + activeNotificationActionsMenu = null + if (submenu && submenu.opened) { + submenu.close() + } + } + + function closeActiveSubmenuIfPointerLeft() { + if (!activeSubmenu || !activeSubmenu.opened) { + closeActiveSubmenu() + return + } + if ((activeSubmenuAnchorRow && activeSubmenuAnchorRow.hovered) + || activeSubmenuContainsPointer()) { + return + } + closeActiveSubmenu() + } + function closeAppsMenu() { - if (appsMenu.opened) { + if (activeSubmenu === appsMenu) { + closeActiveSubmenu() + } else if (appsMenu.opened) { appsMenu.close() } } function closeNotificationActionsMenu() { - if (activeNotificationActionsMenu && activeNotificationActionsMenu.opened) { + if (activeNotificationActionsMenu && activeSubmenu === activeNotificationActionsMenu) { + closeActiveSubmenu() + } else if (activeNotificationActionsMenu && activeNotificationActionsMenu.opened) { activeNotificationActionsMenu.close() } activeNotificationActionsMenu = null @@ -256,6 +313,10 @@ Window { closeNotificationActionsMenu() } + function isActiveSubmenuAnchor(row) { + return !!activeSubmenu && activeSubmenu.opened && activeSubmenuAnchorRow === row + } + function itemIndex(item) { for (let i = 0; i < count; ++i) { if (itemAt(i) === item) { @@ -314,6 +375,21 @@ Window { menu.popup(row, position.x, position.y) } + function popupActiveSubmenuForRow(menu, row) { + activeSubmenuCloseTimer.stop() + if (activeSubmenu && activeSubmenu !== menu) { + const previousSubmenu = activeSubmenu + activeSubmenu = null + activeSubmenuAnchorRow = null + if (previousSubmenu.opened) { + previousSubmenu.close() + } + } + activeSubmenu = menu + activeSubmenuAnchorRow = row + popupSubmenuForRow(menu, row) + } + function repositionOpenSubmenu(menu) { if (menu.opened && menu.anchorRow) { const position = submenuPositionForRow(menu, menu.anchorRow) @@ -321,9 +397,18 @@ Window { } } + function scheduleActiveSubmenuClose(menu) { + if (!menu || activeSubmenu !== menu || !menu.opened) { + return + } + activeSubmenuCloseTimer.restart() + } + MenuItem { id: userStatusHeader + visible: accountDelegate.hasUserStatusSection + height: visible ? implicitHeight : 0 enabled: false text: qsTr("User status") font.pixelSize: Style.trayAccountPopupSecondaryFontSize @@ -347,6 +432,8 @@ Window { readonly property bool isActionable: accountDelegate.onlineStatusEnabled + visible: accountDelegate.hasUserStatusSection + height: visible ? implicitHeight : 0 enabled: statusButton.isActionable text: accountDelegate.currentStatusLabelText() font.pixelSize: Style.trayAccountPopupPrimaryFontSize @@ -398,6 +485,8 @@ Window { } MenuSeparator { + visible: accountDelegate.hasUserStatusSection + height: visible ? implicitHeight : 0 } MenuItem { @@ -457,11 +546,11 @@ Window { if (!enabled) { return } - appsMenu.cancelHoverLeaveClose() + accountActionsMenu.cancelActiveSubmenuClose() accountActionsMenu.closeNotificationActionsMenu() TrayAccountAppsModel.setUserId(accountDelegate.userId) if (!appsMenu.opened) { - accountActionsMenu.popupSubmenuForRow(appsMenu, appsButton) + accountActionsMenu.popupActiveSubmenuForRow(appsMenu, appsButton) } } @@ -469,14 +558,14 @@ Window { if (hovered) { openAppsMenu() } else { - appsMenu.scheduleHoverLeaveClose() + accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) } } onTriggered: openAppsMenu() background: TrayActionHoverBackground { - active: appsButton.hovered || appsMenu.opened + active: appsButton.hovered || accountActionsMenu.isActiveSubmenuAnchor(appsButton) } contentItem: RowLayout { @@ -576,12 +665,8 @@ Window { if (!hasNotificationActions) { return } - if (accountActionsMenu.activeNotificationActionsMenu - && accountActionsMenu.activeNotificationActionsMenu !== notificationActionsMenu) { - accountActionsMenu.activeNotificationActionsMenu.close() - } if (!notificationActionsMenu.opened) { - accountActionsMenu.popupSubmenuForRow(notificationActionsMenu, notificationRow) + accountActionsMenu.popupActiveSubmenuForRow(notificationActionsMenu, notificationRow) } accountActionsMenu.activeNotificationActionsMenu = notificationActionsMenu } @@ -600,7 +685,7 @@ Window { onTriggered: openNotification() background: TrayActionHoverBackground { - active: notificationRow.hovered || notificationActionsMenu.opened + active: notificationRow.hovered || accountActionsMenu.isActiveSubmenuAnchor(notificationRow) } contentItem: RowLayout { @@ -655,6 +740,7 @@ Window { AutoSizingMenu { id: notificationActionsMenu + readonly property bool containsMouse: notificationActionsMenuHoverHandler.hovered property var anchorRow: null width: Style.trayNotificationActionsMenuWidth @@ -664,13 +750,27 @@ Window { modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onHeightChanged: accountActionsMenu.repositionOpenSubmenu(notificationActionsMenu) + onContainsMouseChanged: { + if (containsMouse) { + accountActionsMenu.cancelActiveSubmenuClose() + } else { + accountActionsMenu.scheduleActiveSubmenuClose(notificationActionsMenu) + } + } onClosed: { anchorRow = null + accountActionsMenu.clearActiveSubmenu(notificationActionsMenu) if (accountActionsMenu.activeNotificationActionsMenu === notificationActionsMenu) { accountActionsMenu.activeNotificationActionsMenu = null } } + HoverHandler { + id: notificationActionsMenuHoverHandler + + target: notificationActionsMenu.contentItem + } + Repeater { model: notificationRow.notificationActions @@ -903,8 +1003,8 @@ Window { AutoSizingMenu { id: appsMenu + readonly property bool containsMouse: appsMenuHoverHandler.hovered property var anchorRow: null - property var hoveredAppEntry: null width: Style.trayAccountAppsMenuWidth topPadding: Style.trayAccountPopupActionVerticalPadding @@ -913,32 +1013,22 @@ Window { modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) + onContainsMouseChanged: { + if (containsMouse) { + accountActionsMenu.cancelActiveSubmenuClose() + } else { + accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) + } + } onClosed: { anchorRow = null - hoveredAppEntry = null - hoverLeaveCloseTimer.stop() + accountActionsMenu.clearActiveSubmenu(appsMenu) } - function cancelHoverLeaveClose() { - hoverLeaveCloseTimer.stop() - } + HoverHandler { + id: appsMenuHoverHandler - function scheduleHoverLeaveClose() { - if (opened && !appsButton.hovered && !hoveredAppEntry) { - hoverLeaveCloseTimer.restart() - } - } - - Timer { - id: hoverLeaveCloseTimer - - interval: 120 - repeat: false - onTriggered: { - if (appsMenu.opened && !appsButton.hovered && !appsMenu.hoveredAppEntry) { - appsMenu.close() - } - } + target: appsMenu.contentItem } Repeater { @@ -950,15 +1040,6 @@ Window { text: model.appName font.pixelSize: Style.trayAccountPopupPrimaryFontSize hoverEnabled: true - onHoveredChanged: { - if (hovered) { - appsMenu.hoveredAppEntry = appEntry - appsMenu.cancelHoverLeaveClose() - } else if (appsMenu.hoveredAppEntry === appEntry) { - appsMenu.hoveredAppEntry = null - appsMenu.scheduleHoverLeaveClose() - } - } function appIconSource() { if (!model.appIconUrl || model.appIconUrl === "") { From 0f3e51c16863dbaef21de25e1f1a0df04e138f9a Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 25 Jun 2026 17:49:33 +0200 Subject: [PATCH 14/20] fix(UI): test fixes and update Signed-off-by: Rello --- src/gui/tray/TrayAccountPopup.qml | 188 ++++++++++++++++-------------- 1 file changed, 99 insertions(+), 89 deletions(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 10d2cd2410c05..6ccdd5b245eab 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -230,7 +230,17 @@ Window { focus: false modal: false closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - height: implicitHeight + readonly property real calculatedHeight: { + let totalHeight = topPadding + bottomPadding + for (let i = 0; i < count; ++i) { + const item = itemAt(i) + if (item && item.visible !== false) { + totalHeight += item.height > 0 ? item.height : item.implicitHeight + } + } + return totalHeight + } + height: calculatedHeight onHeightChanged: repositionOpenSubmenu(accountActionsMenu) onClosed: { closeSubmenus() @@ -587,6 +597,94 @@ Window { } } + AutoSizingMenu { + id: appsMenu + + readonly property bool containsMouse: appsMenuHoverHandler.hovered + property var anchorRow: null + + width: Style.trayAccountAppsMenuWidth + topPadding: Style.trayAccountPopupActionVerticalPadding + bottomPadding: Style.trayAccountPopupActionVerticalPadding + focus: false + modal: false + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) + onContainsMouseChanged: { + if (containsMouse) { + accountActionsMenu.cancelActiveSubmenuClose() + } else { + accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) + } + } + onClosed: { + anchorRow = null + accountActionsMenu.clearActiveSubmenu(appsMenu) + } + + HoverHandler { + id: appsMenuHoverHandler + + target: appsMenu.contentItem + } + + Repeater { + model: TrayAccountAppsModel + + delegate: MenuItem { + id: appEntry + + text: model.appName + font.pixelSize: Style.trayAccountPopupPrimaryFontSize + hoverEnabled: true + + function appIconSource() { + if (!model.appIconUrl || model.appIconUrl === "") { + return "" + } + return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText + } + + background: TrayActionHoverBackground { + active: appEntry.hovered + } + + contentItem: RowLayout { + spacing: 8 + + Image { + Layout.preferredWidth: Style.trayAccountPopupSyncIconSize + Layout.preferredHeight: Style.trayAccountPopupSyncIconSize + source: appEntry.appIconSource() + sourceSize.width: Style.trayAccountPopupSyncIconSize + sourceSize.height: Style.trayAccountPopupSyncIconSize + fillMode: Image.PreserveAspectFit + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: appEntry.text + font: appEntry.font + color: palette.windowText + elide: Text.ElideRight + } + } + + onTriggered: { + root._closing = true + appsMenu.close() + accountActionsMenu.close() + Systray.hideWindow() + TrayAccountAppsModel.openAppUrl(model.appUrl) + } + + Accessible.role: Accessible.MenuItem + Accessible.name: qsTr("Open %1 in browser").arg(model.appName) + Accessible.onPressAction: appEntry.triggered() + } + } + } + Accessible.role: Accessible.Button Accessible.name: text Accessible.onPressAction: appsButton.triggered() @@ -1000,94 +1098,6 @@ Window { } } - AutoSizingMenu { - id: appsMenu - - readonly property bool containsMouse: appsMenuHoverHandler.hovered - property var anchorRow: null - - width: Style.trayAccountAppsMenuWidth - topPadding: Style.trayAccountPopupActionVerticalPadding - bottomPadding: Style.trayAccountPopupActionVerticalPadding - focus: false - modal: false - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) - onContainsMouseChanged: { - if (containsMouse) { - accountActionsMenu.cancelActiveSubmenuClose() - } else { - accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) - } - } - onClosed: { - anchorRow = null - accountActionsMenu.clearActiveSubmenu(appsMenu) - } - - HoverHandler { - id: appsMenuHoverHandler - - target: appsMenu.contentItem - } - - Repeater { - model: TrayAccountAppsModel - - delegate: MenuItem { - id: appEntry - - text: model.appName - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - - function appIconSource() { - if (!model.appIconUrl || model.appIconUrl === "") { - return "" - } - return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText - } - - background: TrayActionHoverBackground { - active: appEntry.hovered - } - - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - source: appEntry.appIconSource() - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - fillMode: Image.PreserveAspectFit - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: appEntry.text - font: appEntry.font - color: palette.windowText - elide: Text.ElideRight - } - } - - onTriggered: { - root._closing = true - appsMenu.close() - accountActionsMenu.close() - Systray.hideWindow() - TrayAccountAppsModel.openAppUrl(model.appUrl) - } - - Accessible.role: Accessible.MenuItem - Accessible.name: qsTr("Open %1 in browser").arg(model.appName) - Accessible.onPressAction: appEntry.triggered() - } - } - } - contentItem: RowLayout { spacing: Style.trayAccountPopupRowSpacing From 69eda922d78472764b838b0c1a8301c7ad6297a4 Mon Sep 17 00:00:00 2001 From: Jyrki Gadinger Date: Fri, 26 Jun 2026 10:45:41 +0200 Subject: [PATCH 15/20] fix(tray): avoid crash when running on Wayland For some reason the status flag causes QtQuick to crash the app when the window is about to be shown on a display with scaling enabled. Signed-off-by: Jyrki Gadinger --- src/gui/tray/TrayAccountPopup.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml index 6ccdd5b245eab..5f67973306dab 100644 --- a/src/gui/tray/TrayAccountPopup.qml +++ b/src/gui/tray/TrayAccountPopup.qml @@ -1108,7 +1108,6 @@ Window { : (Style.darkMode ? "image://avatars/fallbackWhite" : "image://avatars/fallbackBlack") fillMode: Image.PreserveAspectCrop cache: false - layer.enabled: visible && status === Image.Ready layer.effect: OpacityMask { maskSource: Rectangle { width: Style.trayAccountPopupAvatarSize From a998e4248a9d65520f7cb03f1a2b0cf88b723dd5 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 26 Jun 2026 13:57:31 +0200 Subject: [PATCH 16/20] fix(UI): change QML menu to QMenu for Win/Linux Signed-off-by: Rello --- resources.qrc | 1 - src/gui/CMakeLists.txt | 5 +- src/gui/macOS/trayaccountpopup_mac.mm | 2 +- src/gui/systray.cpp | 29 +- src/gui/systray.h | 8 +- src/gui/tray/TrayAccountPopup.qml | 1389 ------------------------- src/gui/trayaccountpopup_qt.cpp | 490 +++++++++ 7 files changed, 503 insertions(+), 1421 deletions(-) delete mode 100644 src/gui/tray/TrayAccountPopup.qml create mode 100644 src/gui/trayaccountpopup_qt.cpp diff --git a/resources.qrc b/resources.qrc index 8d90e8a125b6f..118fb21077f17 100644 --- a/resources.qrc +++ b/resources.qrc @@ -28,7 +28,6 @@ src/gui/filedetails/ShareeSearchField.qml src/gui/filedetails/ShareView.qml src/gui/tray/MainWindow.qml - src/gui/tray/TrayAccountPopup.qml src/gui/tray/UserLine.qml src/gui/tray/HeaderButton.qml src/gui/tray/SyncStatus.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 70ea4281e0c42..961556fa0db1f 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -340,7 +340,10 @@ IF( APPLE ) install(DIRECTORY "${SPARKLE_LIBRARY}" DESTINATION "${OWNCLOUD_OSX_BUNDLE}/Contents/Frameworks" USE_SOURCE_PERMISSIONS) - endif() + endif() +ENDIF() +IF( NOT APPLE ) + list(APPEND client_SRCS trayaccountpopup_qt.cpp) ENDIF() IF( NOT WIN32 AND NOT APPLE ) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 94c5c17e40011..11a4338d7d252 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -18,7 +18,7 @@ #import -// Keep behavior and layout aligned with src/gui/tray/TrayAccountPopup.qml. +// Keep behavior and menu taxonomy aligned with src/gui/trayaccountpopup_qt.cpp. static const CGFloat kPopupWidth = 300.0; static const CGFloat kRowHeight = 48.0; diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 4957fe3cdd17b..e8865f455dd34 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -31,8 +31,8 @@ #include #include #include -#include #include +#include #ifdef USE_FDO_NOTIFICATIONS #include @@ -179,15 +179,6 @@ void Systray::create() } else { _trayEngine->rootContext()->setContextProperty("activityModel", &_fakeActivityModel); } - -#ifndef Q_OS_MACOS - QQmlComponent popupComponent(trayEngine(), QStringLiteral("qrc:/qml/src/gui/tray/TrayAccountPopup.qml")); - if (popupComponent.isError()) { - qCWarning(lcSystray) << popupComponent.errorString(); - } else { - _popupWindow.reset(qobject_cast(popupComponent.create())); - } -#endif } hideWindow(); emit activated(QSystemTrayIcon::ActivationReason::Unknown); @@ -218,19 +209,7 @@ void Systray::showTrayPopup(WindowPosition position) setIsOpen(true); UserModel::instance()->fetchCurrentActivityModel(); #else - if (!_popupWindow) { - showActivitiesWindow(); - return; - } - - if (position == WindowPosition::Center) { - positionWindowAtScreenCenter(_popupWindow.data()); - } else { - positionWindowAtTray(_popupWindow.data()); - } - _popupWindow->show(); - _popupWindow->raise(); - _popupWindow->requestActivate(); + showQtTrayPopup(geometry(), position); setIsOpen(true); #endif } @@ -244,9 +223,7 @@ void Systray::hideWindow() #ifdef Q_OS_MACOS hideMacOSTrayPopup(); #else - if (_popupWindow) { - _popupWindow->hide(); - } + hideQtTrayPopup(); #endif setIsOpen(false); } diff --git a/src/gui/systray.h b/src/gui/systray.h index 087ed4eba9fea..7e9890a6b86b8 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -220,9 +220,6 @@ private slots: QHash> _activitiesWindows; QHash> _assistantWindows; QPointer _userStatusWindow; -#ifndef Q_OS_MACOS - QSharedPointer _popupWindow; -#endif AccessManagerFactory _accessManagerFactory; @@ -234,6 +231,11 @@ private slots: QStringListModel _fakeActivityModel; }; +#ifndef Q_OS_MACOS +void showQtTrayPopup(const QRect &iconRect, Systray::WindowPosition position); +void hideQtTrayPopup(); +#endif + } // namespace OCC #endif //SYSTRAY_H diff --git a/src/gui/tray/TrayAccountPopup.qml b/src/gui/tray/TrayAccountPopup.qml deleted file mode 100644 index 5f67973306dab..0000000000000 --- a/src/gui/tray/TrayAccountPopup.qml +++ /dev/null @@ -1,1389 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -import QtQuick -import QtQuick.Controls.Basic -import QtQuick.Layouts -import QtQml.Models -import Qt5Compat.GraphicalEffects - -import Style -import com.nextcloud.desktopclient -import com.nextcloud.desktopclient as NC - -// Keep behavior and layout aligned with src/gui/macOS/trayaccountpopup_mac.mm. - -Window { - id: root - - property bool _closing: false - property bool _hadFocusSinceShow: false - property var activeAccountActionsMenu: null - - readonly property bool hasAccounts: UserModel && UserModel.count > 0 - - width: Style.trayAccountPopupWidth - height: contentColumn.height - color: "transparent" - flags: Qt.Tool | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint - - Component.onCompleted: Systray.forceWindowInit(root) - - onVisibleChanged: { - if (visible) { - _hadFocusSinceShow = false - } else { - closeActiveTraySubmenus() - } - } - - onActiveChanged: { - if (active) { - _hadFocusSinceShow = true - } else if (_hadFocusSinceShow && !_closing) { - Systray.hideWindow() - } - _closing = false - } - - function closeActiveAccountActionsMenu() { - if (activeAccountActionsMenu && activeAccountActionsMenu.opened) { - activeAccountActionsMenu.close() - } - activeAccountActionsMenu = null - } - - function closeActiveTraySubmenus() { - closeActiveAccountActionsMenu() - } - - function translatedAskAssistantText() { - return qsTranslate("MainWindow", "Ask Assistant\u00A0…") - } - - component TrayActionHoverBackground: Item { - property bool active: false - property int verticalMargin: 0 - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.leftMargin: Style.trayAccountPopupHoverMargin - anchors.rightMargin: Style.trayAccountPopupHoverMargin - anchors.topMargin: parent.verticalMargin - anchors.bottomMargin: parent.verticalMargin - radius: Style.trayAccountPopupHoverRadius - visible: opacity > 0 - color: Style.darkMode - ? Qt.rgba(1, 1, 1, Style.trayAccountPopupRowHoverOpacity) - : Qt.rgba(0, 0, 0, Style.trayAccountPopupRowHoverOpacity) - opacity: parent.active ? 1.0 : 0.0 - Behavior on opacity { NumberAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } - } - - Rectangle { - id: popupContainer - anchors.fill: parent - radius: Style.trayWindowRadius - color: palette.window - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - clip: true - layer.enabled: root.visible - layer.effect: OpacityMask { - maskSource: Rectangle { - width: popupContainer.width - height: popupContainer.height - radius: popupContainer.radius - visible: false - } - } - - Column { - id: contentColumn - width: parent.width - spacing: 0 - - Item { - width: parent.width - height: Style.trayAccountPopupTopPadding - } - - Repeater { - model: UserModel - - delegate: Column { - id: accountDelegate - readonly property int userId: model.id - readonly property int onlineStatus: model.status - readonly property bool hasUserStatusSection: model.serverHasUserStatus - readonly property bool onlineStatusEnabled: model.isConnected && model.serverHasUserStatus - readonly property url statusIcon: model.statusIcon - readonly property bool hasStatusIcon: statusIcon.toString() !== "" - readonly property string statusMessage: model.statusMessage - readonly property var recentActivities: model.recentActivities ? model.recentActivities : [] - readonly property var trayNotifications: model.trayNotifications ? model.trayNotifications : [] - readonly property var accountAlert: model.accountAlert ? model.accountAlert : ({}) - readonly property string accountAlertTitle: accountAlert.title ? accountAlert.title : "" - readonly property bool hasAccountAlert: accountAlertTitle !== "" - readonly property bool assistantEnabled: model.assistantEnabled - - width: root.width - height: accountRow.height + accountAlertBox.height - spacing: 0 - - function openActivities() { - root._closing = true - Systray.showActivitiesWindow(accountDelegate.userId) - } - - function openLocalFolder() { - root._closing = true - UserModel.currentUserId = accountDelegate.userId - Systray.hideWindow() - if (UserModel.currentUser && UserModel.currentUser.hasLocalFolder) { - UserModel.openCurrentAccountLocalFolder() - } else if (Qt.platform.os === "osx" - && UserModel.currentUser - && UserModel.currentUser.hasFileProvider) { - UserModel.openCurrentAccountFileProviderDomain() - } - } - - function openAssistant() { - root._closing = true - Systray.showAssistantWindow(accountDelegate.userId) - } - - function currentStatusText() { - switch (onlineStatus) { - case NC.userStatus.Away: - return qsTranslate("UserStatusSetStatusView", "Away") - case NC.userStatus.Busy: - return qsTranslate("UserStatusSetStatusView", "Busy") - case NC.userStatus.DoNotDisturb: - return qsTranslate("UserStatusSetStatusView", "Do not disturb") - case NC.userStatus.Invisible: - return qsTranslate("UserStatusSetStatusView", "Invisible") - case NC.userStatus.Offline: - return qsTranslate("OCC::SyncStatusSummary", "Offline") - case NC.userStatus.Online: - default: - return qsTranslate("UserStatusSetStatusView", "Online") - } - } - - function currentStatusLabelText() { - const message = statusMessage.trim() - return message !== "" ? message : currentStatusText() - } - - function openAccountActionsMenu() { - TrayAccountAppsModel.setUserId(accountDelegate.userId) - UserModel.fetchActivityPreview(accountDelegate.userId) - root.closeActiveAccountActionsMenu() - accountActionsMenu.popupSubmenuForRow(accountActionsMenu, accountRow) - root.activeAccountActionsMenu = accountActionsMenu - } - - ItemDelegate { - id: accountRow - - width: root.width - height: Style.trayAccountPopupRowHeight - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - rightPadding: Style.trayAccountPopupRowPadding - - onHoveredChanged: { - if (hovered && !accountActionsMenu.opened) { - openAccountActionsMenu() - } - } - - background: TrayActionHoverBackground { - active: accountRow.hovered || accountActionsMenu.opened - verticalMargin: Style.trayAccountPopupAccountHoverVerticalMargin - } - - AutoSizingMenu { - id: accountActionsMenu - - property var activeNotificationActionsMenu: null - property var activeSubmenu: null - property var activeSubmenuAnchorRow: null - property var anchorRow: null - - width: Style.trayAccountActionsMenuWidth - topPadding: Style.trayAccountPopupActionVerticalPadding - bottomPadding: Style.trayAccountPopupActionVerticalPadding - focus: false - modal: false - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - readonly property real calculatedHeight: { - let totalHeight = topPadding + bottomPadding - for (let i = 0; i < count; ++i) { - const item = itemAt(i) - if (item && item.visible !== false) { - totalHeight += item.height > 0 ? item.height : item.implicitHeight - } - } - return totalHeight - } - height: calculatedHeight - onHeightChanged: repositionOpenSubmenu(accountActionsMenu) - onClosed: { - closeSubmenus() - anchorRow = null - if (root.activeAccountActionsMenu === accountActionsMenu) { - root.activeAccountActionsMenu = null - } - } - - Timer { - id: activeSubmenuCloseTimer - - interval: 180 - repeat: false - onTriggered: accountActionsMenu.closeActiveSubmenuIfPointerLeft() - } - - function activeSubmenuContainsPointer() { - return !!activeSubmenu && activeSubmenu.containsMouse === true - } - - function cancelActiveSubmenuClose() { - activeSubmenuCloseTimer.stop() - } - - function clearActiveSubmenu(menu) { - if (activeSubmenu !== menu) { - return - } - activeSubmenuCloseTimer.stop() - activeSubmenu = null - activeSubmenuAnchorRow = null - if (activeNotificationActionsMenu === menu) { - activeNotificationActionsMenu = null - } - } - - function closeActiveSubmenu() { - const submenu = activeSubmenu - activeSubmenuCloseTimer.stop() - activeSubmenu = null - activeSubmenuAnchorRow = null - activeNotificationActionsMenu = null - if (submenu && submenu.opened) { - submenu.close() - } - } - - function closeActiveSubmenuIfPointerLeft() { - if (!activeSubmenu || !activeSubmenu.opened) { - closeActiveSubmenu() - return - } - if ((activeSubmenuAnchorRow && activeSubmenuAnchorRow.hovered) - || activeSubmenuContainsPointer()) { - return - } - closeActiveSubmenu() - } - - function closeAppsMenu() { - if (activeSubmenu === appsMenu) { - closeActiveSubmenu() - } else if (appsMenu.opened) { - appsMenu.close() - } - } - - function closeNotificationActionsMenu() { - if (activeNotificationActionsMenu && activeSubmenu === activeNotificationActionsMenu) { - closeActiveSubmenu() - } else if (activeNotificationActionsMenu && activeNotificationActionsMenu.opened) { - activeNotificationActionsMenu.close() - } - activeNotificationActionsMenu = null - } - - function closeSubmenus() { - closeAppsMenu() - closeNotificationActionsMenu() - } - - function isActiveSubmenuAnchor(row) { - return !!activeSubmenu && activeSubmenu.opened && activeSubmenuAnchorRow === row - } - - function itemIndex(item) { - for (let i = 0; i < count; ++i) { - if (itemAt(i) === item) { - return i - } - } - return -1 - } - - function insertionIndexAfter(anchorItem, offset) { - const anchorIndex = itemIndex(anchorItem) - return anchorIndex < 0 ? count : anchorIndex + 1 + offset - } - - function submenuPositionForRow(menu, row) { - const menuWidth = Math.max(menu.width, menu.implicitWidth) - const menuHeight = Math.max(menu.height, menu.implicitHeight) - const margin = Style.trayAccountPopupHoverMargin - const rowPosition = row.mapToItem(popupContainer, 0, 0) - const screenLeft = root.screen ? root.screen.virtualX : root.x - const screenTop = root.screen ? root.screen.virtualY : root.y - const screenRight = screenLeft + (root.screen ? root.screen.width : root.width) - const screenBottom = screenTop + (root.screen ? root.screen.height : root.height) - const rightAlignedX = row.width - const leftAlignedX = -menuWidth - const rowScreenX = root.x + rowPosition.x - const rightAlignedScreenRight = rowScreenX + rightAlignedX + menuWidth - const leftAlignedScreenLeft = rowScreenX + leftAlignedX - let menuX = rightAlignedScreenRight > screenRight - margin - && leftAlignedScreenLeft >= screenLeft + margin - ? leftAlignedX - : rightAlignedX - const menuScreenLeft = rowScreenX + menuX - if (menuScreenLeft < screenLeft + margin) { - menuX = screenLeft + margin - rowScreenX - } else if (menuScreenLeft + menuWidth > screenRight - margin) { - menuX = screenRight - margin - menuWidth - rowScreenX - } - - let menuY = 0 - const screenY = root.y + rowPosition.y - const bottomOverflow = screenY + menuHeight - (screenBottom - margin) - if (bottomOverflow > 0) { - menuY -= bottomOverflow - } - if (screenY + menuY < screenTop + margin) { - menuY = screenTop + margin - screenY - } - - return { "x": menuX, "y": menuY } - } - - function popupSubmenuForRow(menu, row) { - menu.anchorRow = row - const position = submenuPositionForRow(menu, row) - menu.popup(row, position.x, position.y) - } - - function popupActiveSubmenuForRow(menu, row) { - activeSubmenuCloseTimer.stop() - if (activeSubmenu && activeSubmenu !== menu) { - const previousSubmenu = activeSubmenu - activeSubmenu = null - activeSubmenuAnchorRow = null - if (previousSubmenu.opened) { - previousSubmenu.close() - } - } - activeSubmenu = menu - activeSubmenuAnchorRow = row - popupSubmenuForRow(menu, row) - } - - function repositionOpenSubmenu(menu) { - if (menu.opened && menu.anchorRow) { - const position = submenuPositionForRow(menu, menu.anchorRow) - menu.popup(menu.anchorRow, position.x, position.y) - } - } - - function scheduleActiveSubmenuClose(menu) { - if (!menu || activeSubmenu !== menu || !menu.opened) { - return - } - activeSubmenuCloseTimer.restart() - } - - MenuItem { - id: userStatusHeader - - visible: accountDelegate.hasUserStatusSection - height: visible ? implicitHeight : 0 - enabled: false - text: qsTr("User status") - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - font.weight: Font.DemiBold - hoverEnabled: false - background: Item {} - contentItem: EnforcedPlainTextLabel { - text: userStatusHeader.text - font: userStatusHeader.font - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight - } - - Accessible.role: Accessible.StaticText - Accessible.name: text - } - - MenuItem { - id: statusButton - - readonly property bool isActionable: accountDelegate.onlineStatusEnabled - - visible: accountDelegate.hasUserStatusSection - height: visible ? implicitHeight : 0 - enabled: statusButton.isActionable - text: accountDelegate.currentStatusLabelText() - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: statusButton.enabled - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeSubmenus() - } - } - background: TrayActionHoverBackground { - active: statusButton.enabled && statusButton.hovered - } - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - visible: accountDelegate.hasStatusIcon - source: accountDelegate.statusIcon - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - cache: false - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: statusButton.text - font: statusButton.font - color: statusButton.enabled ? palette.windowText : palette.mid - elide: Text.ElideRight - } - } - onTriggered: { - if (!statusButton.enabled) { - return - } - root._closing = true - Systray.showUserStatusWindow(accountDelegate.userId) - } - - Accessible.role: statusButton.isActionable ? Accessible.Button : Accessible.StaticText - Accessible.name: text - Accessible.onPressAction: { - if (statusButton.isActionable) { - statusButton.triggered() - } - } - } - - MenuSeparator { - visible: accountDelegate.hasUserStatusSection - height: visible ? implicitHeight : 0 - } - - MenuItem { - id: openLocalFolderButton - - text: qsTranslate("TrayFoldersMenuButton", "Open local folder") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeSubmenus() - } - } - background: TrayActionHoverBackground { - active: openLocalFolderButton.hovered - } - onTriggered: accountDelegate.openLocalFolder() - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: openLocalFolderButton.triggered() - } - - MenuItem { - id: assistantButton - - visible: accountDelegate.assistantEnabled - enabled: accountDelegate.assistantEnabled - height: visible ? implicitHeight : 0 - text: root.translatedAskAssistantText() - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeSubmenus() - } - } - background: TrayActionHoverBackground { - active: assistantButton.hovered - } - onTriggered: accountDelegate.openAssistant() - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: assistantButton.triggered() - } - - MenuItem { - id: appsButton - - text: qsTranslate("TrayWindowHeader", "More apps") - enabled: TrayAccountAppsModel.count > 0 - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - - function openAppsMenu() { - if (!enabled) { - return - } - accountActionsMenu.cancelActiveSubmenuClose() - accountActionsMenu.closeNotificationActionsMenu() - TrayAccountAppsModel.setUserId(accountDelegate.userId) - if (!appsMenu.opened) { - accountActionsMenu.popupActiveSubmenuForRow(appsMenu, appsButton) - } - } - - onHoveredChanged: { - if (hovered) { - openAppsMenu() - } else { - accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) - } - } - - onTriggered: openAppsMenu() - - background: TrayActionHoverBackground { - active: appsButton.hovered || accountActionsMenu.isActiveSubmenuAnchor(appsButton) - } - - contentItem: RowLayout { - spacing: 8 - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: appsButton.text - font: appsButton.font - color: appsButton.enabled ? palette.windowText : palette.mid - elide: Text.ElideRight - } - - EnforcedPlainTextLabel { - text: "›" - font.pixelSize: Style.trayAccountPopupChevronFontSize - color: appsButton.enabled ? palette.windowText : palette.mid - opacity: appsButton.enabled ? 0.35 : 1.0 - } - } - - AutoSizingMenu { - id: appsMenu - - readonly property bool containsMouse: appsMenuHoverHandler.hovered - property var anchorRow: null - - width: Style.trayAccountAppsMenuWidth - topPadding: Style.trayAccountPopupActionVerticalPadding - bottomPadding: Style.trayAccountPopupActionVerticalPadding - focus: false - modal: false - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - onHeightChanged: accountActionsMenu.repositionOpenSubmenu(appsMenu) - onContainsMouseChanged: { - if (containsMouse) { - accountActionsMenu.cancelActiveSubmenuClose() - } else { - accountActionsMenu.scheduleActiveSubmenuClose(appsMenu) - } - } - onClosed: { - anchorRow = null - accountActionsMenu.clearActiveSubmenu(appsMenu) - } - - HoverHandler { - id: appsMenuHoverHandler - - target: appsMenu.contentItem - } - - Repeater { - model: TrayAccountAppsModel - - delegate: MenuItem { - id: appEntry - - text: model.appName - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - - function appIconSource() { - if (!model.appIconUrl || model.appIconUrl === "") { - return "" - } - return "image://tray-image-provider/" + model.appIconUrl + "/" + palette.windowText - } - - background: TrayActionHoverBackground { - active: appEntry.hovered - } - - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - source: appEntry.appIconSource() - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - fillMode: Image.PreserveAspectFit - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: appEntry.text - font: appEntry.font - color: palette.windowText - elide: Text.ElideRight - } - } - - onTriggered: { - root._closing = true - appsMenu.close() - accountActionsMenu.close() - Systray.hideWindow() - TrayAccountAppsModel.openAppUrl(model.appUrl) - } - - Accessible.role: Accessible.MenuItem - Accessible.name: qsTr("Open %1 in browser").arg(model.appName) - Accessible.onPressAction: appEntry.triggered() - } - } - } - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: appsButton.triggered() - } - - MenuSeparator { - } - - MenuItem { - id: notificationsHeader - - visible: accountDelegate.trayNotifications.length > 0 - height: visible ? implicitHeight : 0 - enabled: false - text: qsTr("Notifications") - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - font.weight: Font.DemiBold - hoverEnabled: false - background: Item {} - contentItem: EnforcedPlainTextLabel { - text: notificationsHeader.text - font: notificationsHeader.font - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight - } - - Accessible.role: Accessible.StaticText - Accessible.name: text - } - - Instantiator { - model: accountDelegate.trayNotifications - - onObjectAdded: (index, object) => { - accountActionsMenu.insertItem(accountActionsMenu.insertionIndexAfter(notificationsHeader, index), object) - } - onObjectRemoved: (index, object) => { - accountActionsMenu.removeItem(object) - } - - delegate: MenuItem { - id: notificationRow - - required property var modelData - - readonly property var notificationActions: modelData.actions ? modelData.actions : [] - readonly property bool hasNotificationActions: notificationActions.length > 0 - readonly property string rowDateTime: modelData.dateTime ? modelData.dateTime : "" - - text: modelData.title - height: Style.trayAccountPopupPreviewActionHeight - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - - function iconSource() { - if (!modelData.icon || modelData.icon === "") { - return "image://svgimage-custom-color/activity.svg/" + palette.windowText - } - return modelData.icon + "/" + palette.windowText - } - - function openNotification() { - accountActionsMenu.closeSubmenus() - if (modelData.opensSettings === true) { - root._closing = true - accountActionsMenu.close() - Systray.hideWindow() - Systray.openSettings() - return - } - accountDelegate.openActivities() - } - - function openNotificationActionsMenu() { - if (!hasNotificationActions) { - return - } - if (!notificationActionsMenu.opened) { - accountActionsMenu.popupActiveSubmenuForRow(notificationActionsMenu, notificationRow) - } - accountActionsMenu.activeNotificationActionsMenu = notificationActionsMenu - } - - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeAppsMenu() - if (hasNotificationActions) { - openNotificationActionsMenu() - } else { - accountActionsMenu.closeNotificationActionsMenu() - } - } - } - - onTriggered: openNotification() - - background: TrayActionHoverBackground { - active: notificationRow.hovered || accountActionsMenu.isActiveSubmenuAnchor(notificationRow) - } - - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - Layout.alignment: Qt.AlignVCenter - source: notificationRow.iconSource() - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: 0 - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: notificationRow.text - font: notificationRow.font - color: palette.windowText - elide: Text.ElideRight - wrapMode: Text.Wrap - maximumLineCount: 2 - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: notificationRow.rowDateTime - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - color: palette.windowText - opacity: 0.65 - horizontalAlignment: Text.AlignLeft - elide: Text.ElideRight - visible: text !== "" - } - } - - EnforcedPlainTextLabel { - Layout.alignment: Qt.AlignVCenter - visible: notificationRow.hasNotificationActions - text: "›" - font.pixelSize: Style.trayAccountPopupChevronFontSize - color: palette.windowText - opacity: 0.35 - } - } - - AutoSizingMenu { - id: notificationActionsMenu - - readonly property bool containsMouse: notificationActionsMenuHoverHandler.hovered - property var anchorRow: null - - width: Style.trayNotificationActionsMenuWidth - topPadding: Style.trayAccountPopupActionVerticalPadding - bottomPadding: Style.trayAccountPopupActionVerticalPadding - focus: false - modal: false - closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape - onHeightChanged: accountActionsMenu.repositionOpenSubmenu(notificationActionsMenu) - onContainsMouseChanged: { - if (containsMouse) { - accountActionsMenu.cancelActiveSubmenuClose() - } else { - accountActionsMenu.scheduleActiveSubmenuClose(notificationActionsMenu) - } - } - onClosed: { - anchorRow = null - accountActionsMenu.clearActiveSubmenu(notificationActionsMenu) - if (accountActionsMenu.activeNotificationActionsMenu === notificationActionsMenu) { - accountActionsMenu.activeNotificationActionsMenu = null - } - } - - HoverHandler { - id: notificationActionsMenuHoverHandler - - target: notificationActionsMenu.contentItem - } - - Repeater { - model: notificationRow.notificationActions - - delegate: MenuItem { - id: notificationActionMenuItem - - required property var modelData - - text: modelData.label - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - background: TrayActionHoverBackground { - active: notificationActionMenuItem.hovered - } - onTriggered: { - const activityIndex = notificationRow.modelData.activityIndex - const actionIndex = modelData.actionIndex - notificationActionsMenu.close() - if (modelData.actionType === "dismiss") { - UserModel.dismissNotification(accountDelegate.userId, activityIndex) - return - } - if (modelData.actionType === "openActivities") { - accountDelegate.openActivities() - return - } - root._closing = true - accountActionsMenu.close() - Systray.hideWindow() - UserModel.triggerNotificationAction(accountDelegate.userId, activityIndex, actionIndex) - } - - Accessible.role: Accessible.MenuItem - Accessible.name: text - Accessible.onPressAction: notificationActionMenuItem.triggered() - } - } - } - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: notificationRow.triggered() - } - } - - MenuSeparator { - visible: accountDelegate.trayNotifications.length > 0 - height: visible ? Style.trayAccountPopupCompactSeparatorHeight : 0 - } - - MenuItem { - id: lastActivitiesHeader - - enabled: false - text: qsTr("Recent activity") - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - font.weight: Font.DemiBold - hoverEnabled: false - background: Item {} - contentItem: EnforcedPlainTextLabel { - text: lastActivitiesHeader.text - font: lastActivitiesHeader.font - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight - } - - Accessible.role: Accessible.StaticText - Accessible.name: text - } - - Instantiator { - model: accountDelegate.recentActivities - - onObjectAdded: (index, object) => { - accountActionsMenu.insertItem(accountActionsMenu.insertionIndexAfter(lastActivitiesHeader, index), object) - } - onObjectRemoved: (index, object) => { - accountActionsMenu.removeItem(object) - } - - delegate: MenuItem { - id: recentActivityRow - - required property var modelData - - readonly property string rowDateTime: modelData.dateTime ? modelData.dateTime : "" - readonly property string rowSubtitle: modelData.subtitle ? modelData.subtitle : "" - - enabled: true - text: modelData.title - height: rowSubtitle !== "" ? Style.trayAccountPopupDetailedPreviewActionHeight - : Style.trayAccountPopupPreviewActionHeight - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - background: TrayActionHoverBackground { - active: recentActivityRow.hovered - } - - function iconSource() { - if (!modelData.icon || modelData.icon === "") { - return "image://svgimage-custom-color/activity.svg/" + palette.windowText - } - return modelData.icon + "/" + palette.windowText - } - - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - Layout.alignment: Qt.AlignVCenter - source: recentActivityRow.iconSource() - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: 0 - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: recentActivityRow.text - font.pixelSize: recentActivityRow.font.pixelSize - font.weight: Font.DemiBold - color: palette.windowText - elide: Text.ElideRight - maximumLineCount: 1 - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: recentActivityRow.rowSubtitle - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight - maximumLineCount: 1 - visible: text !== "" - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: recentActivityRow.rowDateTime - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - color: palette.windowText - opacity: 0.65 - horizontalAlignment: Text.AlignLeft - elide: Text.ElideRight - visible: text !== "" - } - } - } - - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeSubmenus() - } - } - - onTriggered: accountDelegate.openActivities() - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: recentActivityRow.triggered() - } - } - - MenuItem { - id: noRecentActivitiesRow - - visible: accountDelegate.recentActivities.length === 0 - height: visible ? implicitHeight : 0 - enabled: false - text: qsTr("No recent activity") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: false - background: Item {} - contentItem: RowLayout { - spacing: 8 - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - Layout.alignment: Qt.AlignVCenter - source: "image://svgimage-custom-color/activity.svg/" + palette.windowText - sourceSize.width: Style.trayAccountPopupSyncIconSize - sourceSize.height: Style.trayAccountPopupSyncIconSize - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: noRecentActivitiesRow.text - font: noRecentActivitiesRow.font - color: palette.windowText - opacity: 0.7 - elide: Text.ElideRight - } - } - - Accessible.role: Accessible.StaticText - Accessible.name: text - } - - MenuItem { - id: moreActivitiesButton - - text: qsTr("More activity…") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - hoverEnabled: true - onHoveredChanged: { - if (hovered) { - accountActionsMenu.closeSubmenus() - } - } - background: TrayActionHoverBackground { - active: moreActivitiesButton.hovered - } - onTriggered: accountDelegate.openActivities() - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: moreActivitiesButton.triggered() - } - } - - contentItem: RowLayout { - spacing: Style.trayAccountPopupRowSpacing - - Image { - Layout.preferredWidth: Style.trayAccountPopupAvatarSize - Layout.preferredHeight: Style.trayAccountPopupAvatarSize - source: model.avatar !== "" ? model.avatar - : (Style.darkMode ? "image://avatars/fallbackWhite" : "image://avatars/fallbackBlack") - fillMode: Image.PreserveAspectCrop - cache: false - layer.effect: OpacityMask { - maskSource: Rectangle { - width: Style.trayAccountPopupAvatarSize - height: Style.trayAccountPopupAvatarSize - radius: width / 2 - visible: false - } - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 1 - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: model.name - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - font.weight: Font.DemiBold - elide: Text.ElideRight - color: palette.windowText - } - - EnforcedPlainTextLabel { - Layout.fillWidth: true - text: model.server - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - elide: Text.ElideRight - color: palette.windowText - opacity: 0.6 - } - } - - Image { - Layout.preferredWidth: Style.trayAccountPopupSyncIconSize - Layout.preferredHeight: Style.trayAccountPopupSyncIconSize - source: model.syncStatusIcon - sourceSize: Qt.size(Style.trayAccountPopupSyncIconSize, - Style.trayAccountPopupSyncIconSize) - } - - EnforcedPlainTextLabel { - text: "›" - font.pixelSize: Style.trayAccountPopupChevronFontSize - color: palette.windowText - opacity: 0.35 - } - } - - onClicked: { - accountDelegate.openActivities() - } - } - - ItemDelegate { - id: accountAlertBox - - visible: accountDelegate.hasAccountAlert - width: root.width - height: visible ? Math.max(Style.trayAccountPopupActionHeight, - accountAlertLabel.implicitHeight - + (2 * Style.trayAccountPopupAccountHoverVerticalMargin)) : 0 - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - rightPadding: Style.trayAccountPopupRowPadding - - background: Item {} - - contentItem: RowLayout { - spacing: Style.trayAccountPopupRowSpacing - - Item { - Layout.preferredWidth: Style.trayAccountPopupAvatarSize - Layout.fillHeight: true - } - - EnforcedPlainTextLabel { - id: accountAlertLabel - - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - text: accountDelegate.accountAlertTitle - font.pixelSize: Style.trayAccountPopupSecondaryFontSize - font.weight: Font.DemiBold - color: palette.windowText - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight - } - - Button { - id: accountAlertResolveButton - - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: Math.max(implicitWidth, 82) - Layout.preferredHeight: Style.trayAccountPopupActionHeight - text: qsTr("Resolve") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - onClicked: accountDelegate.openActivities() - - background: Rectangle { - radius: Style.mediumRoundedButtonRadius - color: accountAlertResolveButton.hovered || accountAlertResolveMouseArea.containsMouse ? palette.mid : palette.button - border.color: palette.mid - border.width: Style.trayWindowBorderWidth - } - - contentItem: EnforcedPlainTextLabel { - text: accountAlertResolveButton.text - font: accountAlertResolveButton.font - color: palette.buttonText - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - MouseArea { - id: accountAlertResolveMouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: accountDelegate.openActivities() - } - - Accessible.role: Accessible.Button - Accessible.name: text - Accessible.onPressAction: accountAlertResolveButton.clicked() - } - } - - onHoveredChanged: { - if (hovered) { - root.closeActiveAccountActionsMenu() - } - } - - onClicked: accountDelegate.openActivities() - - Accessible.role: Accessible.Button - Accessible.name: accountDelegate.accountAlertTitle - Accessible.onPressAction: accountAlertBox.clicked() - } - } - } - - Rectangle { - visible: root.hasAccounts - width: parent.width - height: visible ? Style.trayWindowBorderWidth : 0 - color: palette.mid - opacity: Style.darkMode ? 1.0 : 0.5 - } - - Item { - width: parent.width - height: Style.trayAccountPopupActionVerticalPadding - } - - ItemDelegate { - id: addAccountRow - visible: Systray.enableAddAccount - width: root.width - height: visible ? Style.trayAccountPopupActionHeight : 0 - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - - background: TrayActionHoverBackground { - active: addAccountRow.hovered - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Add account") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - onHoveredChanged: { - if (hovered) { - root.closeActiveTraySubmenus() - } - } - - onClicked: { - root._closing = true - Systray.hideWindow() - Systray.openAccountWizard() - } - } - - ItemDelegate { - id: settingsRow - width: root.width - height: Style.trayAccountPopupActionHeight - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - - background: TrayActionHoverBackground { - active: settingsRow.hovered - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Settings") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - onHoveredChanged: { - if (hovered) { - root.closeActiveTraySubmenus() - } - } - - onClicked: { - root._closing = true - Systray.hideWindow() - Systray.openSettings() - } - } - - ItemDelegate { - id: quitRow - width: root.width - height: Style.trayAccountPopupActionHeight - hoverEnabled: true - topInset: 0 - leftInset: 0 - rightInset: 0 - bottomInset: 0 - padding: 0 - leftPadding: Style.trayAccountPopupRowPadding - - background: TrayActionHoverBackground { - active: quitRow.hovered - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Quit") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - onHoveredChanged: { - if (hovered) { - root.closeActiveTraySubmenus() - } - } - - onClicked: { - root._closing = true - Systray.shutdown() - } - } - - Item { - width: parent.width - height: Style.trayAccountPopupActionVerticalPadding - } - } - } -} diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp new file mode 100644 index 0000000000000..785ce969c83eb --- /dev/null +++ b/src/gui/trayaccountpopup_qt.cpp @@ -0,0 +1,490 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "systray.h" + +#include "theme.h" +#include "tray/trayaccountappsmodel.h" +#include "tray/usermodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OCC { + +// Keep behavior and menu taxonomy aligned with src/gui/macOS/trayaccountpopup_mac.mm. + +namespace { + +QPointer s_trayPopup; + +QString statusText(const UserStatus::OnlineStatus status) +{ + switch (status) { + case UserStatus::OnlineStatus::Online: + return QCoreApplication::translate("UserStatusSetStatusView", "Online"); + case UserStatus::OnlineStatus::Away: + return QCoreApplication::translate("UserStatusSetStatusView", "Away"); + case UserStatus::OnlineStatus::Busy: + return QCoreApplication::translate("UserStatusSetStatusView", "Busy"); + case UserStatus::OnlineStatus::DoNotDisturb: + return QCoreApplication::translate("UserStatusSetStatusView", "Do not disturb"); + case UserStatus::OnlineStatus::Invisible: + return QCoreApplication::translate("UserStatusSetStatusView", "Invisible"); + case UserStatus::OnlineStatus::Offline: + return QCoreApplication::translate("OCC::SyncStatusSummary", "Offline"); + } + return QCoreApplication::translate("UserStatusSetStatusView", "Online"); +} + +QString statusMenuText(const UserStatus::OnlineStatus status, const QString &message) +{ + const auto trimmedMessage = message.trimmed(); + return trimmedMessage.isEmpty() ? statusText(status) : trimmedMessage; +} + +QIcon themeIcon(const QString &iconName) +{ + return Theme::createColorAwareIcon(QStringLiteral(":/client/theme/%1").arg(iconName)); +} + +QIcon blackThemeIcon(const QString &iconName) +{ + return Theme::createColorAwareIcon(QStringLiteral(":/client/theme/black/%1").arg(iconName)); +} + +QIcon iconFromUrl(const QUrl &url) +{ + if (url.isEmpty()) { + return {}; + } + if (url.isLocalFile()) { + return QIcon(url.toLocalFile()); + } + if (url.scheme() == QStringLiteral("qrc")) { + return QIcon(QStringLiteral(":%1").arg(url.path())); + } + if (url.scheme().isEmpty()) { + return QIcon(url.toString()); + } + return {}; +} + +QIcon iconFromImage(const QImage &image) +{ + return image.isNull() ? QIcon{} : QIcon(QPixmap::fromImage(image)); +} + +QIcon activityIcon(const QVariantMap &activityData) +{ + const auto systemIconName = activityData.value(QStringLiteral("systemIconName")).toString(); + if (systemIconName == QStringLiteral("bell")) { + return blackThemeIcon(QStringLiteral("bell.svg")); + } + if (systemIconName == QStringLiteral("exclamationmark.triangle")) { + return themeIcon(QStringLiteral("warning.svg")); + } + if (systemIconName == QStringLiteral("trash")) { + return themeIcon(QStringLiteral("delete.svg")); + } + if (systemIconName == QStringLiteral("pencil")) { + return themeIcon(QStringLiteral("change.svg")); + } + if (systemIconName == QStringLiteral("message")) { + return blackThemeIcon(QStringLiteral("comment.svg")); + } + if (systemIconName == QStringLiteral("calendar")) { + return blackThemeIcon(QStringLiteral("calendar.svg")); + } + return blackThemeIcon(QStringLiteral("activity.svg")); +} + +QString titleWithDetail(const QString &title, const QString &subtitle, const QString &dateTime) +{ + auto result = title; + if (!subtitle.isEmpty()) { + result = QStringLiteral("%1 - %2").arg(result, subtitle); + } + if (!dateTime.isEmpty()) { + result = QStringLiteral("%1 (%2)").arg(result, dateTime); + } + return result; +} + +QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) +{ + return icon.isNull() ? menu->addAction(text) : menu->addAction(icon, text); +} + +void closeTrayPopup() +{ + if (s_trayPopup) { + s_trayPopup->close(); + } +} + +void openActivitiesForUser(const int userId) +{ + closeTrayPopup(); + Systray::instance()->showActivitiesWindow(userId); +} + +void openLocalFolderForUser(const int userId) +{ + closeTrayPopup(); + + const auto userModel = UserModel::instance(); + if (!userModel) { + return; + } + + userModel->setCurrentUserId(userId); + const auto user = userModel->currentUser(); + if (!user) { + return; + } + + if (user->hasLocalFolder()) { + userModel->openCurrentAccountLocalFolder(); + } +#ifdef BUILD_FILE_PROVIDER_MODULE + else if (user->hasFileProvider()) { + userModel->openCurrentAccountFileProviderDomain(); + } +#endif +} + +void openUserStatusForUser(const int userId) +{ + closeTrayPopup(); + Systray::instance()->showUserStatusWindow(userId); +} + +void openAssistantForUser(const int userId) +{ + closeTrayPopup(); + Systray::instance()->showAssistantWindow(userId); +} + +void openNotification(const int userId, const bool opensSettings) +{ + closeTrayPopup(); + if (opensSettings) { + Systray::instance()->openSettings(); + return; + } + Systray::instance()->showActivitiesWindow(userId); +} + +bool populateAppsMenu(QMenu *menu, const int userId) +{ + menu->clear(); + + const auto appsModel = TrayAccountAppsModel::instance(); + appsModel->setUserId(userId); + const auto appCount = appsModel->rowCount(); + + for (auto row = 0; row < appCount; ++row) { + const auto appIndex = appsModel->index(row); + const auto appName = appsModel->data(appIndex, TrayAccountAppsModel::NameRole).toString(); + const auto appUrl = appsModel->data(appIndex, TrayAccountAppsModel::UrlRole).toUrl(); + auto appIcon = iconFromUrl(appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl()); + if (appIcon.isNull()) { + appIcon = blackThemeIcon(QStringLiteral("more-apps.svg")); + } + + const auto action = addMenuAction(menu, appIcon, appName); + QObject::connect(action, &QAction::triggered, action, [appUrl] { + closeTrayPopup(); + TrayAccountAppsModel::instance()->openAppUrl(appUrl); + }); + } + + if (menu->isEmpty()) { + const auto noAppsAction = menu->addAction(QCoreApplication::translate("TrayAccountPopup", "No apps available")); + noAppsAction->setEnabled(false); + } + + return appCount > 0; +} + +void populateNotificationActionsMenu(QMenu *menu, const int userId, const int activityIndex, const QVariantList &actions) +{ + for (const auto &actionVariant : actions) { + const auto actionData = actionVariant.toMap(); + const auto title = actionData.value(QStringLiteral("label")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto actionType = actionData.value(QStringLiteral("actionType")).toString(); + const auto actionIndex = actionData.value(QStringLiteral("actionIndex")).toInt(); + const auto action = menu->addAction(title); + QObject::connect(action, &QAction::triggered, action, [userId, activityIndex, actionType, actionIndex] { + if (actionType == QStringLiteral("dismiss")) { + UserModel::instance()->dismissNotification(userId, activityIndex); + return; + } + if (actionType == QStringLiteral("openActivities")) { + openActivitiesForUser(userId); + return; + } + closeTrayPopup(); + UserModel::instance()->triggerNotificationAction(userId, activityIndex, actionIndex); + }); + } +} + +void addNotifications(QMenu *menu, const int userId, const QVariantList ¬ifications) +{ + if (notifications.isEmpty()) { + return; + } + + menu->addSection(QCoreApplication::translate("TrayAccountPopup", "Notifications")); + for (const auto ¬ificationVariant : notifications) { + const auto notificationData = notificationVariant.toMap(); + const auto title = notificationData.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto opensSettings = notificationData.value(QStringLiteral("opensSettings")).toBool(); + const auto activityIndex = notificationData.value(QStringLiteral("activityIndex")).toInt(); + const auto actions = notificationData.value(QStringLiteral("actions")).toList(); + const auto dateTime = notificationData.value(QStringLiteral("dateTime")).toString(); + const auto menuText = titleWithDetail(title, {}, dateTime); + if (actions.isEmpty()) { + const auto action = addMenuAction(menu, activityIcon(notificationData), menuText); + QObject::connect(action, &QAction::triggered, action, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + continue; + } + + const auto notificationMenu = menu->addMenu(activityIcon(notificationData), menuText); + const auto openAction = notificationMenu->addAction(QCoreApplication::translate("TrayAccountPopup", "Open")); + QObject::connect(openAction, &QAction::triggered, openAction, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + notificationMenu->addSeparator(); + populateNotificationActionsMenu(notificationMenu, userId, activityIndex, actions); + } +} + +void addRecentActivities(QMenu *menu, const int userId, const QVariantList &recentActivities) +{ + menu->addSection(QCoreApplication::translate("TrayAccountPopup", "Recent activity")); + + if (recentActivities.isEmpty()) { + const auto noRecentActivity = addMenuAction(menu, + blackThemeIcon(QStringLiteral("activity.svg")), + QCoreApplication::translate("TrayAccountPopup", "No recent activity")); + noRecentActivity->setEnabled(false); + } + + for (const auto &recentActivityVariant : recentActivities) { + const auto activityData = recentActivityVariant.toMap(); + const auto title = activityData.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto subtitle = activityData.value(QStringLiteral("subtitle")).toString(); + const auto dateTime = activityData.value(QStringLiteral("dateTime")).toString(); + const auto action = addMenuAction(menu, activityIcon(activityData), titleWithDetail(title, subtitle, dateTime)); + QObject::connect(action, &QAction::triggered, action, [userId] { + openActivitiesForUser(userId); + }); + } + + const auto moreActivitiesAction = menu->addAction(QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246")); + QObject::connect(moreActivitiesAction, &QAction::triggered, moreActivitiesAction, [userId] { + openActivitiesForUser(userId); + }); +} + +void populateAccountMenu(QMenu *menu, const int userId) +{ + menu->clear(); + + const auto userModel = UserModel::instance(); + if (!userModel || userId < 0 || userId >= userModel->rowCount()) { + return; + } + + userModel->fetchActivityPreview(userId); + + const auto userModelIndex = userModel->index(userId); + const auto serverHasUserStatus = userModel->data(userModelIndex, UserModel::ServerHasUserStatusRole).toBool(); + const auto onlineStatusEnabled = userModel->data(userModelIndex, UserModel::IsConnectedRole).toBool() && serverHasUserStatus; + + const auto openActivitiesAction = addMenuAction(menu, + blackThemeIcon(QStringLiteral("activity.svg")), + QCoreApplication::translate("TrayAccountPopup", "Open activity")); + QObject::connect(openActivitiesAction, &QAction::triggered, openActivitiesAction, [userId] { + openActivitiesForUser(userId); + }); + + const auto accountAlert = userModel->data(userModelIndex, UserModel::AccountAlertRole).toMap(); + const auto accountAlertTitle = accountAlert.value(QStringLiteral("title")).toString(); + if (!accountAlertTitle.isEmpty()) { + const auto resolveAction = addMenuAction(menu, + themeIcon(QStringLiteral("warning.svg")), + QCoreApplication::translate("TrayAccountPopup", "Resolve: %1").arg(accountAlertTitle)); + QObject::connect(resolveAction, &QAction::triggered, resolveAction, [userId] { + openActivitiesForUser(userId); + }); + } + + if (serverHasUserStatus) { + menu->addSection(QCoreApplication::translate("TrayAccountPopup", "User status")); + const auto status = userModel->data(userModelIndex, UserModel::StatusRole).value(); + const auto statusMessage = userModel->data(userModelIndex, UserModel::StatusMessageRole).toString(); + const auto statusAction = addMenuAction(menu, + iconFromUrl(userModel->data(userModelIndex, UserModel::StatusIconRole).toUrl()), + statusMenuText(status, statusMessage)); + statusAction->setEnabled(onlineStatusEnabled); + QObject::connect(statusAction, &QAction::triggered, statusAction, [userId] { + openUserStatusForUser(userId); + }); + } + + menu->addSeparator(); + + const auto openFolderAction = addMenuAction(menu, + themeIcon(QStringLiteral("file-open.svg")), + QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder")); + QObject::connect(openFolderAction, &QAction::triggered, openFolderAction, [userId] { + openLocalFolderForUser(userId); + }); + + const auto assistantEnabled = userModel->data(userModelIndex, UserModel::AssistantEnabledRole).toBool(); + if (assistantEnabled) { + const auto assistantAction = addMenuAction(menu, + blackThemeIcon(QStringLiteral("nc-assistant-app.svg")), + QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246")); + QObject::connect(assistantAction, &QAction::triggered, assistantAction, [userId] { + openAssistantForUser(userId); + }); + } + + const auto appsMenu = menu->addMenu(blackThemeIcon(QStringLiteral("more-apps.svg")), + QCoreApplication::translate("TrayWindowHeader", "More apps")); + appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); + QObject::connect(appsMenu, &QMenu::aboutToShow, appsMenu, [appsMenu, userId] { + appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); + }); + + menu->addSeparator(); + + addNotifications(menu, userId, userModel->data(userModelIndex, UserModel::TrayNotificationsRole).toList()); + addRecentActivities(menu, userId, userModel->data(userModelIndex, UserModel::RecentActivitiesRole).toList()); +} + +void populateTrayMenu(QMenu *menu) +{ + menu->clear(); + + const auto userModel = UserModel::instance(); + if (userModel && userModel->rowCount() > 0) { + for (auto userId = 0; userId < userModel->rowCount(); ++userId) { + const auto userModelIndex = userModel->index(userId); + const auto name = userModel->data(userModelIndex, UserModel::NameRole).toString(); + const auto server = userModel->data(userModelIndex, UserModel::ServerRole).toString(); + const auto accountText = QStringLiteral("%1 (%2)").arg(name, server); + auto accountIcon = iconFromImage(userModel->syncStatusIconForRow(userId)); + if (accountIcon.isNull()) { + accountIcon = Theme::instance()->applicationIcon(); + } + + const auto accountMenu = menu->addMenu(accountIcon, accountText); + QObject::connect(accountMenu, &QMenu::aboutToShow, accountMenu, [accountMenu, userId] { + populateAccountMenu(accountMenu, userId); + }); + } + menu->addSeparator(); + } + + if (Systray::instance()->enableAddAccount()) { + const auto addAccountAction = addMenuAction(menu, themeIcon(QStringLiteral("add.svg")), Systray::tr("Add account")); + QObject::connect(addAccountAction, &QAction::triggered, addAccountAction, [] { + closeTrayPopup(); + Systray::instance()->openAccountWizard(); + }); + } + + const auto settingsAction = addMenuAction(menu, themeIcon(QStringLiteral("settings.svg")), Systray::tr("Settings")); + QObject::connect(settingsAction, &QAction::triggered, settingsAction, [] { + closeTrayPopup(); + Systray::instance()->openSettings(); + }); + + const auto quitAction = addMenuAction(menu, themeIcon(QStringLiteral("close.svg")), Systray::tr("Quit")); + QObject::connect(quitAction, &QAction::triggered, quitAction, [] { + closeTrayPopup(); + Systray::instance()->shutdown(); + }); +} + +QPoint trayPopupPosition(const QMenu *menu, const QRect &iconRect, const Systray::WindowPosition position) +{ + if (position == Systray::WindowPosition::Center) { + const auto cursorScreen = QGuiApplication::screenAt(QCursor::pos()); + const auto screen = cursorScreen ? cursorScreen : QGuiApplication::primaryScreen(); + if (!screen) { + return QCursor::pos(); + } + + const auto menuSize = menu->sizeHint(); + const auto screenCenter = screen->availableGeometry().center(); + return screenCenter - QPoint(menuSize.width() / 2, menuSize.height() / 2); + } + + if (iconRect.isValid() && !iconRect.isNull()) { + return iconRect.center(); + } + + return QCursor::pos(); +} + +} // namespace + +void showQtTrayPopup(const QRect &iconRect, const Systray::WindowPosition position) +{ + hideQtTrayPopup(); + + const auto menu = new QMenu(); + s_trayPopup = menu; + populateTrayMenu(menu); + + QObject::connect(menu, &QMenu::aboutToHide, menu, [menu] { + if (s_trayPopup == menu) { + s_trayPopup = nullptr; + } + Systray::instance()->setIsOpen(false); + menu->deleteLater(); + }); + + menu->popup(trayPopupPosition(menu, iconRect, position)); +} + +void hideQtTrayPopup() +{ + if (s_trayPopup) { + s_trayPopup->close(); + } +} + +} // namespace OCC From 90e27f430b63d6b1df07daba3756e5fc0a805196 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 26 Jun 2026 15:18:58 +0200 Subject: [PATCH 17/20] fix(UI): change QML menu to QMenu for Win/Linux Signed-off-by: Rello --- src/gui/trayaccountpopup_qt.cpp | 161 ++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 19 deletions(-) diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 785ce969c83eb..81a7481bbea2c 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -5,6 +5,9 @@ #include "systray.h" +#include "accountmanager.h" +#include "accountstate.h" +#include "iconjob.h" #include "theme.h" #include "tray/trayaccountappsmodel.h" #include "tray/usermodel.h" @@ -13,11 +16,17 @@ #include #include #include +#include #include +#include #include +#include +#include +#include #include #include #include +#include #include #include @@ -28,6 +37,46 @@ namespace OCC { namespace { QPointer s_trayPopup; +QHash s_remoteAppIconCache; + +QRectF aspectFitRect(const QSize &sourceSize, const QSize &targetSize) +{ + if (!sourceSize.isValid() || !targetSize.isValid()) { + return QRectF(QPointF(0.0, 0.0), targetSize); + } + + const auto scaledSize = sourceSize.scaled(targetSize, Qt::KeepAspectRatio); + return QRectF(QPointF((targetSize.width() - scaledSize.width()) / 2.0, + (targetSize.height() - scaledSize.height()) / 2.0), + scaledSize); +} + +QImage imageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + if (imageData.isEmpty()) { + return {}; + } + + const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); + if (mimetype.isValid() && mimetype.inherits(QStringLiteral("image/svg+xml"))) { + auto renderer = QSvgRenderer{}; + if (!renderer.load(imageData)) { + return {}; + } + + auto image = QImage(requestedSize, QImage::Format_ARGB32); + image.fill(Qt::transparent); + auto painter = QPainter(&image); + renderer.render(&painter, aspectFitRect(renderer.defaultSize(), requestedSize)); + return image; + } + + auto image = QImage::fromData(imageData); + if (!image.isNull() && requestedSize.isValid()) { + image = image.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return image; +} QString statusText(const UserStatus::OnlineStatus status) { @@ -86,6 +135,51 @@ QIcon iconFromImage(const QImage &image) return image.isNull() ? QIcon{} : QIcon(QPixmap::fromImage(image)); } +bool isRemoteIconUrl(const QUrl &url) +{ + return url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https"); +} + +QString remoteIconCacheKey(const AccountStatePtr &accountState, const QUrl &url) +{ + if (!accountState || !accountState->account()) { + return {}; + } + return QStringLiteral("%1:%2").arg(accountState->account()->id(), url.toString()); +} + +void fetchRemoteAppIcon(QAction *action, const AccountStatePtr &accountState, const QUrl &iconUrl) +{ + if (!action || !accountState || !accountState->account() || !isRemoteIconUrl(iconUrl)) { + return; + } + + const auto cacheKey = remoteIconCacheKey(accountState, iconUrl); + if (cacheKey.isEmpty()) { + return; + } + + if (s_remoteAppIconCache.contains(cacheKey)) { + action->setIcon(s_remoteAppIconCache.value(cacheKey)); + return; + } + + const auto actionPointer = QPointer(action); + auto iconJob = new IconJob(accountState->account(), iconUrl, action); + QObject::connect(iconJob, &IconJob::jobFinished, action, [actionPointer, cacheKey](const QByteArray &iconData) { + const auto image = imageFromImageData(iconData, QSize(18, 18)); + if (image.isNull()) { + return; + } + + const auto icon = QIcon(QPixmap::fromImage(image)); + s_remoteAppIconCache.insert(cacheKey, icon); + if (actionPointer) { + actionPointer->setIcon(icon); + } + }); +} + QIcon activityIcon(const QVariantMap &activityData) { const auto systemIconName = activityData.value(QStringLiteral("systemIconName")).toString(); @@ -127,6 +221,24 @@ QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) return icon.isNull() ? menu->addAction(text) : menu->addAction(icon, text); } +QPoint clampedMenuPosition(const QPoint &position, const QSize &menuSize, const QRect &availableGeometry) +{ + auto clampedPosition = position; + if (clampedPosition.x() + menuSize.width() > availableGeometry.right() + 1) { + clampedPosition.setX(availableGeometry.right() - menuSize.width() + 1); + } + if (clampedPosition.x() < availableGeometry.left()) { + clampedPosition.setX(availableGeometry.left()); + } + if (clampedPosition.y() + menuSize.height() > availableGeometry.bottom() + 1) { + clampedPosition.setY(availableGeometry.bottom() - menuSize.height() + 1); + } + if (clampedPosition.y() < availableGeometry.top()) { + clampedPosition.setY(availableGeometry.top()); + } + return clampedPosition; +} + void closeTrayPopup() { if (s_trayPopup) { @@ -194,17 +306,23 @@ bool populateAppsMenu(QMenu *menu, const int userId) const auto appsModel = TrayAccountAppsModel::instance(); appsModel->setUserId(userId); const auto appCount = appsModel->rowCount(); + const auto accounts = AccountManager::instance()->accounts(); + const auto accountState = userId >= 0 && userId < accounts.size() + ? accounts.at(userId) + : AccountStatePtr{}; for (auto row = 0; row < appCount; ++row) { const auto appIndex = appsModel->index(row); const auto appName = appsModel->data(appIndex, TrayAccountAppsModel::NameRole).toString(); const auto appUrl = appsModel->data(appIndex, TrayAccountAppsModel::UrlRole).toUrl(); - auto appIcon = iconFromUrl(appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl()); + const auto appIconUrl = appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl(); + auto appIcon = iconFromUrl(appIconUrl); if (appIcon.isNull()) { appIcon = blackThemeIcon(QStringLiteral("more-apps.svg")); } const auto action = addMenuAction(menu, appIcon, appName); + fetchRemoteAppIcon(action, accountState, appIconUrl); QObject::connect(action, &QAction::triggered, action, [appUrl] { closeTrayPopup(); TrayAccountAppsModel::instance()->openAppUrl(appUrl); @@ -330,13 +448,6 @@ void populateAccountMenu(QMenu *menu, const int userId) const auto serverHasUserStatus = userModel->data(userModelIndex, UserModel::ServerHasUserStatusRole).toBool(); const auto onlineStatusEnabled = userModel->data(userModelIndex, UserModel::IsConnectedRole).toBool() && serverHasUserStatus; - const auto openActivitiesAction = addMenuAction(menu, - blackThemeIcon(QStringLiteral("activity.svg")), - QCoreApplication::translate("TrayAccountPopup", "Open activity")); - QObject::connect(openActivitiesAction, &QAction::triggered, openActivitiesAction, [userId] { - openActivitiesForUser(userId); - }); - const auto accountAlert = userModel->data(userModelIndex, UserModel::AccountAlertRole).toMap(); const auto accountAlertTitle = accountAlert.value(QStringLiteral("title")).toString(); if (!accountAlertTitle.isEmpty()) { @@ -440,23 +551,35 @@ void populateTrayMenu(QMenu *menu) QPoint trayPopupPosition(const QMenu *menu, const QRect &iconRect, const Systray::WindowPosition position) { - if (position == Systray::WindowPosition::Center) { - const auto cursorScreen = QGuiApplication::screenAt(QCursor::pos()); - const auto screen = cursorScreen ? cursorScreen : QGuiApplication::primaryScreen(); - if (!screen) { - return QCursor::pos(); - } + const auto cursorScreen = QGuiApplication::screenAt(QCursor::pos()); + const auto trayScreen = iconRect.isValid() && !iconRect.isNull() + ? QGuiApplication::screenAt(iconRect.center()) + : nullptr; + const auto screen = trayScreen ? trayScreen : (cursorScreen ? cursorScreen : QGuiApplication::primaryScreen()); + if (!screen) { + return QCursor::pos(); + } - const auto menuSize = menu->sizeHint(); - const auto screenCenter = screen->availableGeometry().center(); - return screenCenter - QPoint(menuSize.width() / 2, menuSize.height() / 2); + const auto availableGeometry = screen->availableGeometry(); + const auto menuSize = menu->sizeHint(); + + if (position == Systray::WindowPosition::Center) { + const auto screenCenter = availableGeometry.center(); + return clampedMenuPosition(screenCenter - QPoint(menuSize.width() / 2, menuSize.height() / 2), menuSize, availableGeometry); } + auto positionPoint = QCursor::pos(); if (iconRect.isValid() && !iconRect.isNull()) { - return iconRect.center(); + const auto trayCenter = iconRect.center(); + positionPoint.setX(trayCenter.x() - menuSize.width() / 2); + if (trayCenter.y() < availableGeometry.center().y()) { + positionPoint.setY(availableGeometry.top()); + } else { + positionPoint.setY(availableGeometry.bottom() - menuSize.height() + 1); + } } - return QCursor::pos(); + return clampedMenuPosition(positionPoint, menuSize, availableGeometry); } } // namespace From 536c5e0929063ab1d4e03788c0fafa9d15ec1d87 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 26 Jun 2026 16:11:50 +0200 Subject: [PATCH 18/20] fix(UI): change QML menu to QMenu for Win/Linux Signed-off-by: Rello --- src/gui/trayaccountpopup_qt.cpp | 60 ++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 81a7481bbea2c..b9aeae2aa451a 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -13,6 +13,7 @@ #include "tray/usermodel.h" #include +#include #include #include #include @@ -22,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -78,6 +80,22 @@ QImage imageFromImageData(const QByteArray &imageData, const QSize &requestedSiz return image; } +QImage tintImage(const QImage &image, const QColor &color) +{ + if (image.isNull() || !color.isValid()) { + return image; + } + + auto tintedImage = QImage(image.size(), QImage::Format_ARGB32_Premultiplied); + tintedImage.fill(color); + + auto painter = QPainter(&tintedImage); + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawImage(0, 0, image.convertToFormat(QImage::Format_ARGB32_Premultiplied)); + painter.end(); + return tintedImage; +} + QString statusText(const UserStatus::OnlineStatus status) { switch (status) { @@ -140,21 +158,27 @@ bool isRemoteIconUrl(const QUrl &url) return url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https"); } -QString remoteIconCacheKey(const AccountStatePtr &accountState, const QUrl &url) +QString remoteIconCacheKey(const AccountStatePtr &accountState, const QUrl &url, const QColor &iconColor) { if (!accountState || !accountState->account()) { return {}; } - return QStringLiteral("%1:%2").arg(accountState->account()->id(), url.toString()); + return QStringLiteral("%1:%2:%3").arg( + accountState->account()->id(), + iconColor.name(QColor::HexArgb), + url.toString()); } -void fetchRemoteAppIcon(QAction *action, const AccountStatePtr &accountState, const QUrl &iconUrl) +void fetchRemoteAppIcon(QAction *action, + const AccountStatePtr &accountState, + const QUrl &iconUrl, + const QColor &iconColor) { if (!action || !accountState || !accountState->account() || !isRemoteIconUrl(iconUrl)) { return; } - const auto cacheKey = remoteIconCacheKey(accountState, iconUrl); + const auto cacheKey = remoteIconCacheKey(accountState, iconUrl, iconColor); if (cacheKey.isEmpty()) { return; } @@ -166,18 +190,21 @@ void fetchRemoteAppIcon(QAction *action, const AccountStatePtr &accountState, co const auto actionPointer = QPointer(action); auto iconJob = new IconJob(accountState->account(), iconUrl, action); - QObject::connect(iconJob, &IconJob::jobFinished, action, [actionPointer, cacheKey](const QByteArray &iconData) { - const auto image = imageFromImageData(iconData, QSize(18, 18)); - if (image.isNull()) { - return; - } + QObject::connect(iconJob, + &IconJob::jobFinished, + action, + [actionPointer, cacheKey, iconColor](const QByteArray &iconData) { + const auto image = imageFromImageData(iconData, QSize(18, 18)); + if (image.isNull()) { + return; + } - const auto icon = QIcon(QPixmap::fromImage(image)); - s_remoteAppIconCache.insert(cacheKey, icon); - if (actionPointer) { - actionPointer->setIcon(icon); - } - }); + const auto icon = QIcon(QPixmap::fromImage(tintImage(image, iconColor))); + s_remoteAppIconCache.insert(cacheKey, icon); + if (actionPointer) { + actionPointer->setIcon(icon); + } + }); } QIcon activityIcon(const QVariantMap &activityData) @@ -310,6 +337,7 @@ bool populateAppsMenu(QMenu *menu, const int userId) const auto accountState = userId >= 0 && userId < accounts.size() ? accounts.at(userId) : AccountStatePtr{}; + const auto appIconColor = menu->palette().color(QPalette::Active, QPalette::Text); for (auto row = 0; row < appCount; ++row) { const auto appIndex = appsModel->index(row); @@ -322,7 +350,7 @@ bool populateAppsMenu(QMenu *menu, const int userId) } const auto action = addMenuAction(menu, appIcon, appName); - fetchRemoteAppIcon(action, accountState, appIconUrl); + fetchRemoteAppIcon(action, accountState, appIconUrl, appIconColor); QObject::connect(action, &QAction::triggered, action, [appUrl] { closeTrayPopup(); TrayAccountAppsModel::instance()->openAppUrl(appUrl); From 2408e4ced123e3478594e9c7a9052559d6cfa178 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 26 Jun 2026 17:36:49 +0200 Subject: [PATCH 19/20] fix(UI): change QML menu to QMenu for Win/Linux Signed-off-by: Rello --- src/gui/trayaccountpopup_qt.cpp | 76 ++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index b9aeae2aa451a..b1aefed4822c5 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -96,6 +97,50 @@ QImage tintImage(const QImage &image, const QColor &color) return tintedImage; } +QPalette nativeMenuIconPalette(const QMenu *menu) +{ + if (menu && menu->style()) { + return menu->style()->standardPalette(); + } + return QGuiApplication::palette(); +} + +QString templateIconPaletteCacheKey(const QPalette &palette) +{ + return QStringLiteral("%1:%2:%3").arg( + palette.color(QPalette::Active, QPalette::Text).name(QColor::HexArgb), + palette.color(QPalette::Active, QPalette::HighlightedText).name(QColor::HexArgb), + palette.color(QPalette::Disabled, QPalette::Text).name(QColor::HexArgb)); +} + +void addTemplatePixmap(QIcon &icon, const QImage &image, const QColor &color, const QIcon::Mode mode) +{ + icon.addPixmap(QPixmap::fromImage(tintImage(image, color)), mode); +} + +QIcon templateIconFromImage(const QImage &image, const QPalette &palette) +{ + if (image.isNull()) { + return {}; + } + + auto icon = QIcon{}; + addTemplatePixmap(icon, image, palette.color(QPalette::Active, QPalette::Text), QIcon::Normal); + addTemplatePixmap(icon, image, palette.color(QPalette::Active, QPalette::Text), QIcon::Active); + addTemplatePixmap(icon, image, palette.color(QPalette::Active, QPalette::HighlightedText), QIcon::Selected); + addTemplatePixmap(icon, image, palette.color(QPalette::Disabled, QPalette::Text), QIcon::Disabled); + return icon; +} + +QIcon templateIconFromIcon(const QIcon &icon, const QSize &requestedSize, const QPalette &palette) +{ + if (icon.isNull()) { + return {}; + } + + return templateIconFromImage(icon.pixmap(requestedSize).toImage(), palette); +} + QString statusText(const UserStatus::OnlineStatus status) { switch (status) { @@ -158,27 +203,27 @@ bool isRemoteIconUrl(const QUrl &url) return url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https"); } -QString remoteIconCacheKey(const AccountStatePtr &accountState, const QUrl &url, const QColor &iconColor) +QString remoteIconCacheKey(const AccountStatePtr &accountState, const QUrl &url, const QPalette &iconPalette) { if (!accountState || !accountState->account()) { return {}; } return QStringLiteral("%1:%2:%3").arg( accountState->account()->id(), - iconColor.name(QColor::HexArgb), + templateIconPaletteCacheKey(iconPalette), url.toString()); } void fetchRemoteAppIcon(QAction *action, const AccountStatePtr &accountState, const QUrl &iconUrl, - const QColor &iconColor) + const QPalette &iconPalette) { if (!action || !accountState || !accountState->account() || !isRemoteIconUrl(iconUrl)) { return; } - const auto cacheKey = remoteIconCacheKey(accountState, iconUrl, iconColor); + const auto cacheKey = remoteIconCacheKey(accountState, iconUrl, iconPalette); if (cacheKey.isEmpty()) { return; } @@ -193,13 +238,13 @@ void fetchRemoteAppIcon(QAction *action, QObject::connect(iconJob, &IconJob::jobFinished, action, - [actionPointer, cacheKey, iconColor](const QByteArray &iconData) { + [actionPointer, cacheKey, iconPalette](const QByteArray &iconData) { const auto image = imageFromImageData(iconData, QSize(18, 18)); if (image.isNull()) { return; } - const auto icon = QIcon(QPixmap::fromImage(tintImage(image, iconColor))); + const auto icon = templateIconFromImage(image, iconPalette); s_remoteAppIconCache.insert(cacheKey, icon); if (actionPointer) { actionPointer->setIcon(icon); @@ -248,6 +293,14 @@ QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) return icon.isNull() ? menu->addAction(text) : menu->addAction(icon, text); } +QAction *addSectionHeading(QMenu *menu, const QString &text) +{ + const auto action = menu->addAction(text); + action->setEnabled(false); + action->setIconVisibleInMenu(false); + return action; +} + QPoint clampedMenuPosition(const QPoint &position, const QSize &menuSize, const QRect &availableGeometry) { auto clampedPosition = position; @@ -337,7 +390,7 @@ bool populateAppsMenu(QMenu *menu, const int userId) const auto accountState = userId >= 0 && userId < accounts.size() ? accounts.at(userId) : AccountStatePtr{}; - const auto appIconColor = menu->palette().color(QPalette::Active, QPalette::Text); + const auto appIconPalette = nativeMenuIconPalette(menu); for (auto row = 0; row < appCount; ++row) { const auto appIndex = appsModel->index(row); @@ -348,9 +401,10 @@ bool populateAppsMenu(QMenu *menu, const int userId) if (appIcon.isNull()) { appIcon = blackThemeIcon(QStringLiteral("more-apps.svg")); } + appIcon = templateIconFromIcon(appIcon, QSize(18, 18), appIconPalette); const auto action = addMenuAction(menu, appIcon, appName); - fetchRemoteAppIcon(action, accountState, appIconUrl, appIconColor); + fetchRemoteAppIcon(action, accountState, appIconUrl, appIconPalette); QObject::connect(action, &QAction::triggered, action, [appUrl] { closeTrayPopup(); TrayAccountAppsModel::instance()->openAppUrl(appUrl); @@ -398,7 +452,7 @@ void addNotifications(QMenu *menu, const int userId, const QVariantList ¬ific return; } - menu->addSection(QCoreApplication::translate("TrayAccountPopup", "Notifications")); + addSectionHeading(menu, QCoreApplication::translate("TrayAccountPopup", "Notifications")); for (const auto ¬ificationVariant : notifications) { const auto notificationData = notificationVariant.toMap(); const auto title = notificationData.value(QStringLiteral("title")).toString(); @@ -431,7 +485,7 @@ void addNotifications(QMenu *menu, const int userId, const QVariantList ¬ific void addRecentActivities(QMenu *menu, const int userId, const QVariantList &recentActivities) { - menu->addSection(QCoreApplication::translate("TrayAccountPopup", "Recent activity")); + addSectionHeading(menu, QCoreApplication::translate("TrayAccountPopup", "Recent activity")); if (recentActivities.isEmpty()) { const auto noRecentActivity = addMenuAction(menu, @@ -488,7 +542,7 @@ void populateAccountMenu(QMenu *menu, const int userId) } if (serverHasUserStatus) { - menu->addSection(QCoreApplication::translate("TrayAccountPopup", "User status")); + addSectionHeading(menu, QCoreApplication::translate("TrayAccountPopup", "User status")); const auto status = userModel->data(userModelIndex, UserModel::StatusRole).value(); const auto statusMessage = userModel->data(userModelIndex, UserModel::StatusMessageRole).toString(); const auto statusAction = addMenuAction(menu, From 639f7a858f5e0d0904d408584fbe3a76808f9c27 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 26 Jun 2026 18:39:05 +0200 Subject: [PATCH 20/20] fix(UI): change QML menu to QMenu for Win/Linux Signed-off-by: Rello --- src/gui/trayaccountpopup_qt.cpp | 70 +++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index b1aefed4822c5..4daca799c0714 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -13,6 +13,7 @@ #include "tray/usermodel.h" #include +#include #include #include #include @@ -20,9 +21,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -32,6 +36,7 @@ #include #include #include +#include namespace OCC { @@ -141,6 +146,16 @@ QIcon templateIconFromIcon(const QIcon &icon, const QSize &requestedSize, const return templateIconFromImage(icon.pixmap(requestedSize).toImage(), palette); } +QIcon templateThemeIcon(const QString &iconName, const QPalette &palette) +{ + return templateIconFromIcon(QIcon(QStringLiteral(":/client/theme/%1").arg(iconName)), QSize(18, 18), palette); +} + +QIcon templateBlackThemeIcon(const QString &iconName, const QPalette &palette) +{ + return templateIconFromIcon(QIcon(QStringLiteral(":/client/theme/black/%1").arg(iconName)), QSize(18, 18), palette); +} + QString statusText(const UserStatus::OnlineStatus status) { switch (status) { @@ -295,9 +310,15 @@ QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) QAction *addSectionHeading(QMenu *menu, const QString &text) { - const auto action = menu->addAction(text); + auto action = new QWidgetAction(menu); + auto label = new QLabel(text, menu); + label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + label->setContentsMargins(10, 4, 10, 2); + label->setEnabled(false); + label->setFont(menu->font()); action->setEnabled(false); - action->setIconVisibleInMenu(false); + action->setDefaultWidget(label); + menu->addAction(action); return action; } @@ -481,6 +502,8 @@ void addNotifications(QMenu *menu, const int userId, const QVariantList ¬ific notificationMenu->addSeparator(); populateNotificationActionsMenu(notificationMenu, userId, activityIndex, actions); } + + menu->addSeparator(); } void addRecentActivities(QMenu *menu, const int userId, const QVariantList &recentActivities) @@ -488,8 +511,9 @@ void addRecentActivities(QMenu *menu, const int userId, const QVariantList &rece addSectionHeading(menu, QCoreApplication::translate("TrayAccountPopup", "Recent activity")); if (recentActivities.isEmpty()) { + const auto menuIconPalette = nativeMenuIconPalette(menu); const auto noRecentActivity = addMenuAction(menu, - blackThemeIcon(QStringLiteral("activity.svg")), + templateBlackThemeIcon(QStringLiteral("activity.svg"), menuIconPalette), QCoreApplication::translate("TrayAccountPopup", "No recent activity")); noRecentActivity->setEnabled(false); } @@ -515,7 +539,7 @@ void addRecentActivities(QMenu *menu, const int userId, const QVariantList &rece }); } -void populateAccountMenu(QMenu *menu, const int userId) +void populateAccountMenu(QMenu *menu, const int userId, const bool fetchActivityPreview = true) { menu->clear(); @@ -524,9 +548,12 @@ void populateAccountMenu(QMenu *menu, const int userId) return; } - userModel->fetchActivityPreview(userId); + if (fetchActivityPreview) { + userModel->fetchActivityPreview(userId); + } const auto userModelIndex = userModel->index(userId); + const auto menuIconPalette = nativeMenuIconPalette(menu); const auto serverHasUserStatus = userModel->data(userModelIndex, UserModel::ServerHasUserStatusRole).toBool(); const auto onlineStatusEnabled = userModel->data(userModelIndex, UserModel::IsConnectedRole).toBool() && serverHasUserStatus; @@ -557,7 +584,7 @@ void populateAccountMenu(QMenu *menu, const int userId) menu->addSeparator(); const auto openFolderAction = addMenuAction(menu, - themeIcon(QStringLiteral("file-open.svg")), + templateThemeIcon(QStringLiteral("file-open.svg"), menuIconPalette), QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder")); QObject::connect(openFolderAction, &QAction::triggered, openFolderAction, [userId] { openLocalFolderForUser(userId); @@ -566,14 +593,14 @@ void populateAccountMenu(QMenu *menu, const int userId) const auto assistantEnabled = userModel->data(userModelIndex, UserModel::AssistantEnabledRole).toBool(); if (assistantEnabled) { const auto assistantAction = addMenuAction(menu, - blackThemeIcon(QStringLiteral("nc-assistant-app.svg")), + templateBlackThemeIcon(QStringLiteral("nc-assistant-app.svg"), menuIconPalette), QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246")); QObject::connect(assistantAction, &QAction::triggered, assistantAction, [userId] { openAssistantForUser(userId); }); } - const auto appsMenu = menu->addMenu(blackThemeIcon(QStringLiteral("more-apps.svg")), + const auto appsMenu = menu->addMenu(templateBlackThemeIcon(QStringLiteral("more-apps.svg"), menuIconPalette), QCoreApplication::translate("TrayWindowHeader", "More apps")); appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); QObject::connect(appsMenu, &QMenu::aboutToShow, appsMenu, [appsMenu, userId] { @@ -591,6 +618,7 @@ void populateTrayMenu(QMenu *menu) menu->clear(); const auto userModel = UserModel::instance(); + const auto menuIconPalette = nativeMenuIconPalette(menu); if (userModel && userModel->rowCount() > 0) { for (auto userId = 0; userId < userModel->rowCount(); ++userId) { const auto userModelIndex = userModel->index(userId); @@ -606,25 +634,45 @@ void populateTrayMenu(QMenu *menu) QObject::connect(accountMenu, &QMenu::aboutToShow, accountMenu, [accountMenu, userId] { populateAccountMenu(accountMenu, userId); }); + QObject::connect(userModel, + &QAbstractItemModel::dataChanged, + accountMenu, + [accountMenu, userId](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { + if (!accountMenu->isVisible() || userId < topLeft.row() || userId > bottomRight.row()) { + return; + } + if (!roles.isEmpty() + && !roles.contains(UserModel::RecentActivitiesRole) + && !roles.contains(UserModel::TrayNotificationsRole)) { + return; + } + populateAccountMenu(accountMenu, userId, false); + }); } menu->addSeparator(); } if (Systray::instance()->enableAddAccount()) { - const auto addAccountAction = addMenuAction(menu, themeIcon(QStringLiteral("add.svg")), Systray::tr("Add account")); + const auto addAccountAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("add.svg"), menuIconPalette), + Systray::tr("Add account")); QObject::connect(addAccountAction, &QAction::triggered, addAccountAction, [] { closeTrayPopup(); Systray::instance()->openAccountWizard(); }); } - const auto settingsAction = addMenuAction(menu, themeIcon(QStringLiteral("settings.svg")), Systray::tr("Settings")); + const auto settingsAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("settings.svg"), menuIconPalette), + Systray::tr("Settings")); QObject::connect(settingsAction, &QAction::triggered, settingsAction, [] { closeTrayPopup(); Systray::instance()->openSettings(); }); - const auto quitAction = addMenuAction(menu, themeIcon(QStringLiteral("close.svg")), Systray::tr("Quit")); + const auto quitAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("close.svg"), menuIconPalette), + Systray::tr("Quit")); QObject::connect(quitAction, &QAction::triggered, quitAction, [] { closeTrayPopup(); Systray::instance()->shutdown();