From a2ab1222c231bdbed09fc9958e61da24bae2273a Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 2 Jul 2026 21:46:04 +0200 Subject: [PATCH 01/19] feat(tray): update account status menu Consolidates the tray account status menu work and related follow-up fixes into one reviewable commit. Assisted-by: Codex:gpt-5 Signed-off-by: Rello --- resources.qrc | 7 +- src/gui/ActivitiesWindow.qml | 182 ++ src/gui/AssistantWindow.qml | 279 +++ src/gui/CMakeLists.txt | 7 +- src/gui/EmojiPicker.qml | 75 +- src/gui/UserStatusWindow.qml | 355 ++++ .../UserStatusWindowPredefinedStatusRow.qml | 78 + src/gui/UserStatusWindowStatusRow.qml | 79 + src/gui/WindowAccountHeader.qml | 98 + src/gui/application.cpp | 2 +- src/gui/macOS/trayaccountpopup_mac.mm | 1858 +++++++++++++++-- src/gui/owncloudgui.cpp | 6 +- src/gui/settingsdialog.cpp | 2 +- src/gui/systray.cpp | 406 +++- src/gui/systray.h | 20 +- src/gui/tray/ActivityItemActions.qml | 2 +- src/gui/tray/AutoSizingMenu.qml | 2 + src/gui/tray/MainWindow.qml | 7 +- src/gui/tray/SyncStatus.qml | 33 +- src/gui/tray/TrayAccountPopup.qml | 314 --- src/gui/tray/activitylistmodel.cpp | 515 ++++- src/gui/tray/activitylistmodel.h | 12 +- src/gui/tray/asyncimageresponse.cpp | 19 +- src/gui/tray/syncstatussummary.cpp | 11 +- src/gui/tray/syncstatussummary.h | 3 + src/gui/tray/trayaccountappsmodel.cpp | 117 ++ src/gui/tray/trayaccountappsmodel.h | 56 + src/gui/tray/trayimageutils.h | 26 + src/gui/tray/usermodel.cpp | 391 +++- src/gui/tray/usermodel.h | 28 +- src/gui/trayaccountpopup_qt.cpp | 915 ++++++++ src/gui/userstatusselectormodel.cpp | 49 +- src/gui/userstatusselectormodel.h | 13 + src/gui/wizard/qml/WizardDialogFrame.qml | 8 +- src/libsync/ocsuserstatusconnector.cpp | 7 +- src/libsync/ocsuserstatusconnector.h | 2 +- src/libsync/userstatusconnector.h | 3 +- test/testsetuserstatusdialog.cpp | 142 +- theme/Style/Style.qml | 43 +- 39 files changed, 5481 insertions(+), 691 deletions(-) create mode 100644 src/gui/ActivitiesWindow.qml create mode 100644 src/gui/AssistantWindow.qml 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/WindowAccountHeader.qml delete mode 100644 src/gui/tray/TrayAccountPopup.qml create mode 100644 src/gui/tray/trayaccountappsmodel.cpp create mode 100644 src/gui/tray/trayaccountappsmodel.h create mode 100644 src/gui/tray/trayimageutils.h create mode 100644 src/gui/trayaccountpopup_qt.cpp diff --git a/resources.qrc b/resources.qrc index ba9cdae0e0706..118fb21077f17 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,6 +1,12 @@ 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 src/gui/EmojiPicker.qml src/gui/UserStatusSelectorButton.qml @@ -22,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/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/CMakeLists.txt b/src/gui/CMakeLists.txt index 1e570318da00b..82200ff5e74c7 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -221,6 +221,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 @@ -334,7 +336,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/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..8c95ae46a2ae3 --- /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.Basic +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 + + 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 { + visible: emojiButton.hovered || emojiButton.activeFocus + color: Style.wizardRowBackground + } + } + + 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 + } + } + + 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 + } + + 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/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/application.cpp b/src/gui/application.cpp index 40cba58df8b56..15ec96696d419 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -850,7 +850,7 @@ void Application::slotownCloudWizardDone(int res) Utility::setLaunchOnStartup(_theme->appName(), _theme->appNameGUI(), true); - Systray::instance()->showWindow(); + Systray::instance()->showTrayPopup(); } } diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 67e1a07a719fd..0ad7f21d7dbf1 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -4,28 +4,231 @@ */ #include "systray.h" +#include "accountmanager.h" +#include "iconjob.h" +#include "tray/trayaccountappsmodel.h" #include "tray/usermodel.h" +#include +#include +#include +#include #include +#include +#include +#include +#include #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; 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; static const CGFloat kScreenEdgePadding = 8.0; static const CGFloat kStatusItemLeadingOffset = 3.0; -static const CGFloat kStatusItemVerticalOffset = 2.0; static const CGFloat kHoverMargin = 5.0; static const CGFloat kHoverRadius = 5.0; static const CGFloat kAccountHoverVerticalMargin = 4.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); + +static QHash s_remoteAppIconCache; + +static CGFloat clampedPopupOriginCoordinate(const CGFloat origin, const CGFloat minEdge, const CGFloat maxEdge, const CGFloat size) +{ + const auto minOrigin = minEdge + kScreenEdgePadding; + const auto maxOrigin = maxEdge - size - kScreenEdgePadding; + if (maxOrigin < minOrigin) { + return minOrigin; + } + return origin < minOrigin ? minOrigin : (origin > maxOrigin ? maxOrigin : origin); +} + +static NSPoint clampedPopupOrigin(const NSPoint origin, const NSSize size, const NSRect visibleFrame) +{ + return NSMakePoint(clampedPopupOriginCoordinate(origin.x, NSMinX(visibleFrame), NSMaxX(visibleFrame), size.width), + clampedPopupOriginCoordinate(origin.y, NSMinY(visibleFrame), NSMaxY(visibleFrame), size.height)); +} + +static NSScreen *nsScreenForQtScreen(QScreen *qtScreen) +{ + if (!qtScreen) { + return NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + + const auto qtScreenName = qtScreen->name().toNSString(); + for (NSScreen *candidate in NSScreen.screens) { + if ([candidate.localizedName isEqualToString:qtScreenName]) { + return candidate; + } + } + + const auto qtScreens = QGuiApplication::screens(); + const auto screenIndex = qtScreens.indexOf(qtScreen); + if (screenIndex >= 0 && screenIndex < static_cast(NSScreen.screens.count)) { + return [NSScreen.screens objectAtIndex:screenIndex]; + } + + return NSScreen.mainScreen ?: NSScreen.screens.firstObject; +} + +static QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url) +{ + if (!accountState || !accountState->account() || !url.isValid() || url.scheme().isEmpty()) { + return {}; + } + + return QStringLiteral("%1:%2").arg(accountState->account()->id(), url.toString()); +} + +static void addOwnedArrangedSubview(NSStackView *stack, NSView *view) +{ + [stack addArrangedSubview:view]; + [view release]; +} + +static CGFloat textWidth(NSString *text, NSFont *font) +{ + if (text.length == 0 || !font) { + return 0.0; + } + + return [text sizeWithAttributes:@{ NSFontAttributeName : font }].width; +} + +static BOOL labelLikelyClipsText(NSTextField *label, NSString *text, const CGFloat availableWidth) +{ + if (!label || text.length == 0 || availableWidth <= 0.0) { + return NO; + } + + const auto maximumNumberOfLines = label.maximumNumberOfLines > 0 ? label.maximumNumberOfLines : 1; + return textWidth(text, label.font) > availableWidth * maximumNumberOfLines; +} + +static NSString *menuRowToolTipText(NSString *title, NSString *subtitle, NSString *dateTime) +{ + auto parts = [NSMutableArray array]; + if (title.length > 0) { + [parts addObject:title]; + } + if (subtitle.length > 0) { + [parts addObject:subtitle]; + } + if (dateTime.length > 0) { + [parts addObject:dateTime]; + } + + return parts.count > 0 ? [parts componentsJoinedByString:@"\n"] : nil; +} + +static void setSharedToolTip(NSString *toolTip, NSArray *views) +{ + if (toolTip.length == 0) { + return; + } + + for (NSView *view in views) { + view.toolTip = toolTip; + } +} + +static NSStackView *configurePopupPanel(NSPanel *panel) +{ + panel.level = NSPopUpMenuWindowLevel; + panel.hasShadow = YES; + panel.releasedWhenClosed = NO; + panel.backgroundColor = NSColor.clearColor; + panel.opaque = NO; + + auto container = [[[NSView alloc] init] autorelease]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + panel.contentView = container; + + auto visualEffectView = [[[NSVisualEffectView alloc] init] autorelease]; + visualEffectView.material = NSVisualEffectMaterialHUDWindow; + visualEffectView.blendingMode = NSVisualEffectBlendingModeBehindWindow; + visualEffectView.state = NSVisualEffectStateActive; + visualEffectView.wantsLayer = YES; + visualEffectView.layer.cornerRadius = kCornerRadius; + visualEffectView.layer.masksToBounds = YES; + visualEffectView.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:visualEffectView]; + + auto stack = [NSStackView stackViewWithViews:@[]]; + stack.orientation = NSUserInterfaceLayoutOrientationVertical; + stack.spacing = 0; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [visualEffectView addSubview:stack]; + + [NSLayoutConstraint activateConstraints:@[ + [visualEffectView.topAnchor constraintEqualToAnchor:container.topAnchor], + [visualEffectView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [visualEffectView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [visualEffectView.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + [stack.topAnchor constraintEqualToAnchor:visualEffectView.topAnchor], + [stack.leadingAnchor constraintEqualToAnchor:visualEffectView.leadingAnchor], + [stack.trailingAnchor constraintEqualToAnchor:visualEffectView.trailingAnchor], + [stack.bottomAnchor constraintEqualToAnchor:visualEffectView.bottomAnchor], + ]]; + + return stack; +} + +static void clearStack(NSStackView *stack) +{ + auto arrangedSubviews = [stack.arrangedSubviews copy]; + for (NSView *view in arrangedSubviews) { + [stack removeArrangedSubview:view]; + [view removeFromSuperview]; + } + [arrangedSubviews release]; +} + +static void positionPopupFromRow(NSPanel *popup, NSView *row) +{ + 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 = popup.frame.size.width; + const auto popupHeight = popup.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; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin = clampedPopupOrigin(popupOrigin, NSMakeSize(popupWidth, popupHeight), visibleFrame); + + [popup setFrameOrigin:popupOrigin]; +} static NSColor *hoverColor() { @@ -52,10 +255,101 @@ colorSpaceName:NSCalibratedRGBColorSpace bytesPerRow:rgba.bytesPerLine() bitsPerPixel:32]; + if (!rep || !rep.bitmapData) { + [rep release]; + return nil; + } memcpy(rep.bitmapData, rgba.constBits(), (size_t)rgba.bytesPerLine() * rgba.height()); NSImage *img = [[NSImage alloc] initWithSize:NSMakeSize(rgba.width(), rgba.height())]; [img addRepresentation:rep]; - return img; + [rep release]; + return [img autorelease]; +} + +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 {}; + + 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 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) { + 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 statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) +{ + const auto trimmedMessage = message.trimmed(); + return trimmedMessage.isEmpty() ? statusText(status) : trimmedMessage; } @interface NCHoverView : NSView @@ -63,177 +357,1311 @@ @interface NCHoverView : NSView @implementation NCHoverView -- (instancetype)init +- (instancetype)init +{ + self = [super init]; + if (!self) return nil; + self.wantsLayer = YES; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + self.translatesAutoresizingMaskIntoConstraints = NO; + return self; +} + +- (void)mouseEntered:(NSEvent *)event +{ + self.layer.backgroundColor = [NSColor.labelColor colorWithAlphaComponent:0.08].CGColor; +} + +- (void)mouseExited:(NSEvent *)event +{ + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); +} + +- (void)updateTrackingAreas +{ + [super updateTrackingAreas]; + auto trackingAreas = [self.trackingAreas copy]; + for (NSTrackingArea *ta in trackingAreas) { + [self removeTrackingArea:ta]; + } + [trackingAreas release]; + + auto trackingArea = [[NSTrackingArea alloc] + initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea]; + [trackingArea release]; +} + +@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 +{ + self = [super init]; + if (!self) return nil; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + + _hoverView = [[[NSView alloc] init] autorelease]; + _hoverView.wantsLayer = YES; + _hoverView.layer.backgroundColor = hoverColor().CGColor; + _hoverView.layer.cornerRadius = kHoverRadius; + _hoverView.hidden = YES; + [self addSubview:_hoverView]; + return self; +} + +- (void)updateHoverHighlight +{ + _hoverView.hidden = !(_mouseInside || _persistentHighlight); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + +- (void)layout +{ + [super layout]; + _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, kAccountHoverVerticalMargin); +} + +- (void)mouseEntered:(NSEvent *)event +{ + _mouseInside = YES; + [self updateHoverHighlight]; + [self.popupDelegate onAccountRowHovered:self]; +} + +- (void)mouseExited:(NSEvent *)event +{ + _mouseInside = NO; + [self updateHoverHighlight]; +} + +- (void)mouseUp:(NSEvent *)event +{ + [self.popupDelegate onAccountRowClicked:self.userIndex]; +} + +@end + +@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 + 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 + 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; +- (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; +} + +- (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 +{ + 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; + _action = [action copy]; + _hoverAction = [hoverAction copy]; + _actionEnabled = enabled; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + + _hoverView = [[[NSView alloc] init] autorelease]; + _hoverView.wantsLayer = YES; + _hoverView.layer.backgroundColor = hoverColor().CGColor; + _hoverView.layer.cornerRadius = kHoverRadius; + _hoverView.hidden = YES; + [self addSubview:_hoverView]; + + _label = [NSTextField labelWithString:title]; + 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; + + NSView *textContainer = nil; + NSTextField *subtitleLabel = nil; + NSTextField *dateTimeLabel = nil; + if (isPreviewRow) { + textContainer = [[[NSView alloc] init] autorelease]; + 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:@[ + [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] autorelease]; + 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], + ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } + } else { + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } + } + + if (icon) { + _iconView = [[[NSImageView alloc] init] autorelease]; + _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], + ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } + } else { + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } + } + + auto availableTextWidth = width - (kHPad * 2.0); + if (icon) { + availableTextWidth -= 18.0 + 8.0; + } + if (showsSubmenuIndicator) { + availableTextWidth -= 8.0 + 8.0; + } + + auto needsToolTip = labelLikelyClipsText(_label, title, availableTextWidth); + if (subtitleLabel) { + needsToolTip = needsToolTip || labelLikelyClipsText(subtitleLabel, subtitle, availableTextWidth); + } + if (dateTimeLabel) { + needsToolTip = needsToolTip || labelLikelyClipsText(dateTimeLabel, dateTime, availableTextWidth); + } + if (needsToolTip) { + auto toolTipViews = [NSMutableArray arrayWithObjects:self, _label, nil]; + if (textContainer) { + [toolTipViews addObject:textContainer]; + } + if (subtitleLabel) { + [toolTipViews addObject:subtitleLabel]; + } + if (dateTimeLabel) { + [toolTipViews addObject:dateTimeLabel]; + } + setSharedToolTip(menuRowToolTipText(title, subtitle, dateTime), toolTipViews); + } + + [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); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + +- (void)layout +{ + [super layout]; + _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, 0.0); +} + +- (void)mouseEntered:(NSEvent *)event +{ + _mouseInside = YES; + [self updateHoverHighlight]; + if (_actionEnabled && _hoverAction) _hoverAction(self); +} + +- (void)mouseExited:(NSEvent *)event +{ + _mouseInside = NO; + [self updateHoverHighlight]; +} + +- (void)mouseUp:(NSEvent *)event +{ + if (_actionEnabled && _action) _action(); +} + +- (void)dealloc +{ + [_action release]; + [_hoverAction release]; + [super dealloc]; +} + +@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] autorelease]; + 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]]; + } + + auto availableTextWidth = width - (kHPad * 2.0); + if (icon) { + availableTextWidth -= kActivityPreviewIconSize + 8.0; + } + if (labelLikelyClipsText(label, title, availableTextWidth)) { + setSharedToolTip(title, @[self, label]); + } + + [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 = QCoreApplication::translate("TrayAccountPopup", "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], + ]]; + + const auto availableTextWidth = kPopupWidth - (kHPad + kAvatarSize + 10.0) - kHPad - 76.0 - 8.0; + if (labelLikelyClipsText(label, title, availableTextWidth)) { + setSharedToolTip(title, @[self, label]); + } + + 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] autorelease]; + 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 autorelease]; +} + +static NSView *accountActionsSeparator() { - self = [super init]; - if (!self) return nil; - self.wantsLayer = YES; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - self.translatesAutoresizingMaskIntoConstraints = NO; - return self; + return accountActionsSeparator(kAccountHoverVerticalMargin); } -- (void)mouseEntered:(NSEvent *)event +static NSView *compactAccountActionsSeparator() { - self.layer.backgroundColor = [NSColor.labelColor colorWithAlphaComponent:0.08].CGColor; + return accountActionsSeparator(kCompactSeparatorVerticalMargin); } -- (void)mouseExited:(NSEvent *)event +@interface NCSpacerView : NSView +- (instancetype)initWithHeight:(CGFloat)height; +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width; +@end + +@implementation NCSpacerView + +- (instancetype)initWithHeight:(CGFloat)height { - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + return [self initWithHeight:height width:kPopupWidth]; } -- (void)updateTrackingAreas +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width { - [super updateTrackingAreas]; - for (NSTrackingArea *ta in self.trackingAreas.copy) [self removeTrackingArea:ta]; - [self addTrackingArea:[[NSTrackingArea alloc] - initWithRect:self.bounds - options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways - owner:self - userInfo:nil]]; + self = [super init]; + if (!self) return nil; + self.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [self.heightAnchor constraintEqualToConstant:height], + [self.widthAnchor constraintEqualToConstant:width], + ]]; + return self; } @end -@protocol NCAccountRowDelegate -- (void)onAccountRowClicked:(int)index; +@class NCTrayPopup; + +@interface NCTrayPopup : NSPanel +- (void)populate; +- (void)closeAllPopups; +- (void)closeAccountActionsPopup; +- (void)clearActiveAccountRow; +- (void)openActivitiesForIndex:(int)index; +- (void)openLocalFolderForIndex:(int)index; +- (void)openAssistantForIndex:(int)index; +- (void)openOnlineStatusForIndex:(int)index; @end -@interface NCAccountRow : NCHoverView -@property (nonatomic, assign) int userIndex; -@property (nonatomic, assign) id popupDelegate; +@interface NCAppsPopup : NSPanel +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; @end -@implementation NCAccountRow { - NSView *_hoverView; +@implementation NCAppsPopup { + NSStackView *_stack; } - (instancetype)init { - self = [super init]; + self = [super initWithContentRect:NSMakeRect(0, 0, kAppsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; if (!self) return nil; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - _hoverView = [[NSView alloc] init]; - _hoverView.wantsLayer = YES; - _hoverView.layer.backgroundColor = hoverColor().CGColor; - _hoverView.layer.cornerRadius = kHoverRadius; - _hoverView.hidden = YES; - [self addSubview:_hoverView]; + _stack = configurePopupPanel(self); return self; } -- (void)layout +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner { - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, kAccountHoverVerticalMargin); + clearStack(_stack); + + __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]]; + addOwnedArrangedSubview(_stack, [[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(); + const auto appIconUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl(); + const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl); + auto appIcon = nsImageFromQUrl(appIconUrl); + if (!appIcon && !appIconCacheKey.isEmpty() && s_remoteAppIconCache.contains(appIconCacheKey)) { + appIcon = nsImageFromQImage(s_remoteAppIconCache.value(appIconCacheKey)); + } + 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 && !appIconCacheKey.isEmpty()) { + auto retainedRow = [actionRow retain]; + auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); + QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow, appIconCacheKey](const QByteArray &iconData) { + const auto image = qImageFromImageData(iconData, QSize(18, 18)); + if (!image.isNull()) { + s_remoteAppIconCache.insert(appIconCacheKey, image); + [retainedRow setIcon:nsImageFromQImage(image)]; + } + [retainedRow release]; + }); + QObject::connect(iconJob, &OCC::IconJob::error, iconJob, [retainedRow](auto) { + [retainedRow release]; + }); + } + [actionRow release]; + } + addOwnedArrangedSubview(_stack, [[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]; } -- (void)mouseEntered:(NSEvent *)event -{ - _hoverView.hidden = NO; +@end + +@interface NCNotificationActionsPopup : NSPanel +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner; +@end + +@implementation NCNotificationActionsPopup { + NSStackView *_stack; } -- (void)mouseExited:(NSEvent *)event +- (instancetype)init { - _hoverView.hidden = YES; + self = [super initWithContentRect:NSMakeRect(0, 0, kNotificationActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + _stack = configurePopupPanel(self); + return self; } -- (void)mouseUp:(NSEvent *)event +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner { - [self.popupDelegate onAccountRowClicked:self.userIndex]; + clearStack(_stack); + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCNotificationActionsPopup *weakSelf = self; + addOwnedArrangedSubview(_stack, [[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; + } + + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:title.toNSString() + width:kNotificationActionsPopupWidth + enabled:YES + action:^{ + if (dismisses) { + OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); + [weakSelf orderOut:nil]; + return; + } + if (opensActivities) { + [weakOwner openActivitiesForIndex:userIndex]; + return; + } + + [weakOwner closeAllPopups]; + OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); + }]); + } + addOwnedArrangedSubview(_stack, [[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 NCActionRow : NCHoverView -- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action; +@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 NCActionRow { - dispatch_block_t _action; - NSView *_hoverView; +@implementation NCAccountActionsPopup { + NSStackView *_stack; + NCAppsPopup *_appsPopup; + NCNotificationActionsPopup *_notificationActionsPopup; + NCActionRow *_activeSubmenuRow; + __unsafe_unretained NCTrayPopup *_owner; + QMetaObject::Connection _recentActivitiesConnection; + int _userIndex; } -- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action +- (instancetype)init { - self = [super init]; + self = [super initWithContentRect:NSMakeRect(0, 0, kAccountActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; if (!self) return nil; - _action = [action copy]; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - _hoverView = [[NSView alloc] init]; - _hoverView.wantsLayer = YES; - _hoverView.layer.backgroundColor = hoverColor().CGColor; - _hoverView.layer.cornerRadius = kHoverRadius; - _hoverView.hidden = YES; - [self addSubview:_hoverView]; + _userIndex = -1; + _stack = configurePopupPanel(self); + return self; +} - NSTextField *label = [NSTextField labelWithString:title]; - label.font = [NSFont systemFontOfSize:13]; - label.textColor = NSColor.labelColor; - label.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:label]; +- (BOOL)canBecomeKeyWindow { return NO; } - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], - [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], - [self.heightAnchor constraintEqualToConstant:kActionHeight], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], - ]]; - return self; +- (void)dealloc +{ + [_appsPopup release]; + [_notificationActionsPopup release]; + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + } + [super dealloc]; } -- (void)layout +- (BOOL)isShowingActivitiesForUserIndex:(int)userIndex { - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, 0.0); + return [self isVisible] && _userIndex == userIndex; } -- (void)mouseEntered:(NSEvent *)event +- (void)orderOut:(id)sender { - _hoverView.hidden = NO; + [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + _recentActivitiesConnection = {}; + } + _userIndex = -1; + [super orderOut:sender]; } -- (void)mouseExited:(NSEvent *)event +- (void)clearActiveSubmenuRow { - _hoverView.hidden = YES; + [_activeSubmenuRow setPersistentHighlight:NO]; + _activeSubmenuRow = nil; } -- (void)mouseUp:(NSEvent *)event +- (void)hideAppsPopup { - if (_action) _action(); + [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; } -@end +- (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex +{ + if (!_appsPopup) { + _appsPopup = [[NCAppsPopup alloc] init]; + } -@interface NCSpacerView : NSView -- (instancetype)initWithHeight:(CGFloat)height; -@end + [_notificationActionsPopup orderOut:nil]; + [_appsPopup populateForUserIndex:userIndex owner:_owner]; + [self clearActiveSubmenuRow]; + if ([row isKindOfClass:[NCActionRow class]]) { + _activeSubmenuRow = (NCActionRow *)row; + [_activeSubmenuRow setPersistentHighlight:YES]; + } -@implementation NCSpacerView + positionPopupFromRow(_appsPopup, row); + [_appsPopup orderFront:nil]; +} -- (instancetype)initWithHeight:(CGFloat)height +- (void)showNotificationActionsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions { - self = [super init]; - if (!self) return nil; - self.translatesAutoresizingMaskIntoConstraints = NO; - [NSLayoutConstraint activateConstraints:@[ - [self.heightAnchor constraintEqualToConstant:height], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], - ]]; - return self; + 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]; + } + + positionPopupFromRow(_notificationActionsPopup, row); + [_notificationActionsPopup orderFront:nil]; } -@end +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + [self populateForUserIndex:userIndex owner:owner refreshActivities:YES]; +} + +- (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]; + [self clearActiveSubmenuRow]; + + clearStack(_stack); + + 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 serverHasUserStatus = model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); + const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() + && 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(); + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); + 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()); + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() + width:kAccountActionsPopupWidth]); + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() + icon:statusIcon + width:kAccountActionsPopupWidth + enabled:onlineStatusEnabled + action:^{ + [weakOwner openOnlineStatusForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + [_stack addArrangedSubview:accountActionsSeparator()]; + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Local folder").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openLocalFolderForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + if (assistantEnabled) { + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openAssistantForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "More apps").toNSString() + icon:nil + width:kAccountActionsPopupWidth + enabled:appsEnabled + action:^{} + hoverAction:^(NSView *row) { + [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; + } showsSubmenuIndicator:YES]); + + [_stack addArrangedSubview:accountActionsSeparator()]; + + const auto trayNotifications = model->data(userModelIndex, OCC::UserModel::TrayNotificationsRole).toList(); + if (!trayNotifications.isEmpty()) { + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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(); + addOwnedArrangedSubview(_stack, [[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:compactAccountActionsSeparator()]; + } + + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Recent activity").toNSString() + width:kAccountActionsPopupWidth]); + const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); + if (recentActivities.isEmpty()) { + addOwnedArrangedSubview(_stack, [[NCStaticInfoRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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; + } + addOwnedArrangedSubview(_stack, [[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]; + }]); + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kAccountActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + if (preserveTopEdge) { + frame.origin.y = topEdge - frame.size.height; + } + auto screen = self.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + if (screen) { + frame.origin = clampedPopupOrigin(frame.origin, frame.size, screen.visibleFrame); + } + [self setFrame:frame display:NO]; + [self invalidateShadow]; + + if (refreshActivities) { + model->fetchActivityPreview(userIndex); + } +} -@interface NCTrayPopup : NSPanel -- (void)populate; @end @implementation NCTrayPopup { NSStackView *_stack; + NCAccountActionsPopup *_accountActionsPopup; + NCAccountRow *_activeAccountRow; } - (instancetype)init @@ -244,64 +1672,54 @@ - (instancetype)init 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], - ]]; + _stack = configurePopupPanel(self); return self; } - (BOOL)canBecomeKeyWindow { return YES; } +- (void)dealloc +{ + [_accountActionsPopup release]; + [super dealloc]; +} + - (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 avatar:(NSImage *)avatar syncStatusImage:(NSImage *)syncStatusImage { - NSImageView *avatarView = [[NSImageView alloc] init]; + NSImageView *avatarView = [[[NSImageView alloc] init] autorelease]; avatarView.image = avatar != nil ? avatar : [NSImage imageWithSystemSymbolName:@"person.circle.fill" accessibilityDescription:nil]; avatarView.wantsLayer = YES; @@ -322,15 +1740,15 @@ - (NCAccountRow *)makeRowForIndex:(int)index serverLabel.lineBreakMode = NSLineBreakByTruncatingTail; serverLabel.translatesAutoresizingMaskIntoConstraints = NO; - NCAccountRow *row = [[NCAccountRow alloc] init]; + NCAccountRow *row = [[[NCAccountRow alloc] init] autorelease]; row.userIndex = index; row.popupDelegate = self; - NSImageView *statusView = [[NSImageView alloc] init]; + NSImageView *statusView = [[[NSImageView alloc] init] autorelease]; statusView.image = syncStatusImage; statusView.translatesAutoresizingMaskIntoConstraints = NO; - NSImageView *chevron = [[NSImageView alloc] init]; + NSImageView *chevron = [[[NSImageView alloc] init] autorelease]; chevron.image = [[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:nil] imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:11 weight:NSFontWeightMedium]]; chevron.contentTintColor = NSColor.tertiaryLabelColor; @@ -370,25 +1788,44 @@ - (NCAccountRow *)makeRowForIndex:(int)index [row.widthAnchor constraintEqualToConstant:kPopupWidth], ]]; + const auto labelAvailableWidth = kPopupWidth - kHPad - kAvatarSize - 10.0 - 8.0 - 18.0 - 8.0 - 8.0 - kHPad; + if (labelLikelyClipsText(nameLabel, name, labelAvailableWidth) || labelLikelyClipsText(serverLabel, server, labelAvailableWidth)) { + setSharedToolTip(menuRowToolTipText(name, server, nil), @[row, nameLabel, serverLabel]); + } + return row; } - (void)populate { - for (NSView *v in _stack.arrangedSubviews.copy) { - [_stack removeArrangedSubview:v]; - [v removeFromSuperview]; - } + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + + clearStack(_stack); OCC::UserModel *model = OCC::UserModel::instance(); - [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kTopPadding]]; + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kTopPadding]); for (int i = 0; i < model->rowCount(); ++i) { const QModelIndex idx = model->index(i); NSString *name = model->data(idx, OCC::UserModel::NameRole).toString().toNSString(); 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]]; + 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()) { + addOwnedArrangedSubview(_stack, [[NCAlertBoxRow alloc] initWithTitle:accountAlertTitle.toNSString() + action:^{ + [self openActivitiesForIndex:i]; + } hoverAction:^(NSView *) { + [self closeAccountActionsPopup]; + }]); + } } if (model->rowCount() > 0) { @@ -397,26 +1834,42 @@ - (void)populate sep.translatesAutoresizingMaskIntoConstraints = NO; [_stack addArrangedSubview:sep]; [sep.widthAnchor constraintEqualToConstant:kPopupWidth].active = YES; + [sep release]; } - [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]]; + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); __unsafe_unretained NCTrayPopup *weakSelf = self; if (OCC::Systray::instance()->enableAddAccount()) { - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Add account").toNSString() action:^{ + addOwnedArrangedSubview(_stack, [[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:^{ + addOwnedArrangedSubview(_stack, [[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(); - }]]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() action:^{ + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; + }]); + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() + width:kPopupWidth + enabled:YES + action:^{ OCC::Systray::instance()->shutdown(); - }]]; - [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]]; + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; + }]); + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); [self.contentView layoutSubtreeIfNeeded]; NSRect frame = self.frame; @@ -427,10 +1880,71 @@ - (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]; + + positionPopupFromRow(_accountActionsPopup, row); + [_accountActionsPopup orderFront:nil]; +} + +- (void)openActivitiesForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + OCC::Systray::instance()->showActivitiesWindow(index); +} + +- (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)openAssistantForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->showAssistantWindow(index); +} + +- (void)openOnlineStatusForIndex:(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()->showUserStatusWindow(index); } @end @@ -447,37 +1961,31 @@ void showMacOSTrayPopup(const QRect &iconRect) [s_popup populate]; - NSPoint iconPoint = NSMakePoint(iconRect.x(), iconRect.y()); - NSScreen *screen = nil; - for (NSScreen *candidate in NSScreen.screens) { - if (NSPointInRect(iconPoint, candidate.frame)) { - screen = candidate; - break; - } - } + auto qtScreen = iconRect.isValid() && !iconRect.isNull() + ? QGuiApplication::screenAt(iconRect.center()) + : nullptr; + NSScreen *screen = nsScreenForQtScreen(qtScreen); if (!screen) { screen = NSScreen.screens.firstObject; } - const CGFloat screenH = screen.frame.size.height; const CGFloat popupW = s_popup.frame.size.width; const CGFloat popupH = s_popup.frame.size.height; const NSRect visibleFrame = screen.visibleFrame; CGFloat x, y; - if (iconRect.isValid()) { - x = iconRect.x() - kStatusItemLeadingOffset; - y = screenH - iconRect.bottom() - popupH - kStatusItemVerticalOffset; + if (iconRect.isValid() && !iconRect.isNull() && qtScreen) { + const auto qtScreenGeometry = qtScreen->geometry(); + x = NSMinX(screen.frame) + iconRect.x() - qtScreenGeometry.x() - kStatusItemLeadingOffset; + y = NSMaxY(screen.frame) - (iconRect.y() + iconRect.height() - qtScreenGeometry.y()) - popupH; } else { x = NSMaxX(visibleFrame) - popupW - kScreenEdgePadding; y = NSMaxY(visibleFrame) - popupH; } - const CGFloat xMin = NSMinX(visibleFrame) + kScreenEdgePadding; - const CGFloat xMax = NSMaxX(visibleFrame) - popupW - kScreenEdgePadding; - x = x < xMin ? xMin : (x > xMax ? xMax : x); + const auto popupOrigin = clampedPopupOrigin(NSMakePoint(x, y), NSMakeSize(popupW, popupH), visibleFrame); - [s_popup setFrameOrigin:NSMakePoint(x, y)]; + [s_popup setFrameOrigin:popupOrigin]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [NSApp activateIgnoringOtherApps:YES]; diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 1fcda11fb55b9..4f644e89f6c46 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -34,6 +34,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" @@ -171,6 +172,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()); @@ -224,7 +226,7 @@ void ownCloudGui::slotOpenSettingsDialog() void ownCloudGui::slotOpenMainDialog() { - _tray->showWindow(); + _tray->showActivitiesWindow(); } void ownCloudGui::slotTrayClicked(QSystemTrayIcon::ActivationReason reason) @@ -245,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 f1dd82993a8be..e272f5ee2ce57 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 d9641b09be0fb..f813fede30cdd 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -18,15 +18,21 @@ #include "callstatechecker.h" #include "guiutility.h" +#ifdef Q_OS_MACOS +#include "foregroundbackground_interface.h" +#endif + #include +#include #include +#include #include #include #include #include #include -#include #include +#include #ifdef USE_FDO_NOTIFICATIONS #include @@ -42,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() @@ -97,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 @@ -105,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); @@ -120,23 +179,6 @@ void Systray::create() } else { _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()) { - qCWarning(lcSystray) << popupComponent.errorString(); - } else { - _popupWindow.reset(qobject_cast(popupComponent.create())); - } -#endif } hideWindow(); emit activated(QSystemTrayIcon::ActivationReason::Unknown); @@ -145,100 +187,278 @@ 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(); + if (!isSystemTrayAvailable()) { + showActivitiesWindow(); return; } + +#ifdef Q_OS_MACOS + showMacOSTrayPopup(geometry()); + setIsOpen(true); + UserModel::instance()->fetchCurrentActivityModel(); #else - if (!useNormalWindow() && _popupWindow) { - positionWindowAtTray(_popupWindow.data()); - _popupWindow->show(); - _popupWindow->raise(); - _popupWindow->requestActivate(); + if (showQtTrayPopup(this, geometry(), position)) { setIsOpen(true); - return; } #endif +} - if (!_trayWindow) { +void Systray::hideWindow() +{ + if (!isOpen()) { return; } - if (position == WindowPosition::Center) { - positionWindowAtScreenCenter(_trayWindow.data()); - } else { - positionWindowAtTray(_trayWindow.data()); - } - _trayWindow->show(); - _trayWindow->raise(); - _trayWindow->requestActivate(); +#ifdef Q_OS_MACOS + hideMacOSTrayPopup(); +#else + hideQtTrayPopup(); +#endif + setIsOpen(false); +} - setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); +void Systray::showQMLWindow() +{ + showActivitiesWindow(); } -void Systray::hideWindow() +void Systray::showActivitiesWindow(int userIndex) { - if (!isOpen()) { + const auto userModel = UserModel::instance(); + if (!userModel) { return; } -#ifdef Q_OS_MACOS - if (!useNormalWindow()) { - hideMacOSTrayPopup(); - if (_trayWindow) { - _trayWindow->hide(); - } - setIsOpen(false); + 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; } -#else - if (!useNormalWindow()) { - if (_popupWindow) { - _popupWindow->hide(); - } - if (_trayWindow) { - _trayWindow->hide(); + + 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(); } - setIsOpen(false); return; } + + _activitiesWindows.insert(windowKey, window); + window->setIcon(Theme::instance()->applicationIcon()); + +#ifdef Q_OS_MACOS + auto *fgbg = new ForegroundBackground(this); + window->installEventFilter(fgbg); #endif - if (!_trayWindow) { +#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; } - _trayWindow->hide(); - setIsOpen(false); + 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::showQMLWindow() +void Systray::showUserStatusWindow(int userIndex) { - if (!_trayWindow) { + const auto userModel = UserModel::instance(); + if (!userModel || userIndex < 0 || userIndex >= userModel->rowCount()) { + qCWarning(lcSystray) << "Invalid user index for user status window:" << userIndex; return; } -#ifdef Q_OS_MACOS - hideMacOSTrayPopup(); -#else - if (_popupWindow) { - _popupWindow->hide(); + + 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 - positionWindowAtTray(_trayWindow.data()); - _trayWindow->show(); - _trayWindow->raise(); - _trayWindow->requestActivate(); - setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + configureMacOSExpandedQuickWindow(_userStatusWindow.data()); +#endif + + connect(_userStatusWindow.data(), &QObject::destroyed, this, [this] { + _userStatusWindow = nullptr; + }); + + positionWindowAtScreenCenter(_userStatusWindow.data()); + _userStatusWindow->show(); + _userStatusWindow->raise(); + _userStatusWindow->requestActivate(); } void Systray::setupContextMenu() @@ -255,10 +475,13 @@ void Systray::setupContextMenu() // will not work on GNOME, as the old menu will not be correctly replaced. setContextMenu(_contextMenu); +#if defined(Q_OS_LINUX) + setupQtTrayContextMenu(_contextMenu, this); +#else 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); @@ -282,6 +505,7 @@ void Systray::setupContextMenu() resumeAction->setVisible(anyPaused); resumeAction->setEnabled(anyPaused); }); +#endif } void Systray::destroyDialog(QQuickWindow *dialog) const @@ -611,14 +835,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) @@ -717,6 +935,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 @@ -811,10 +1044,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/systray.h b/src/gui/systray.h index 15ecfb1998b4c..28e7de333cc4c 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -12,9 +12,11 @@ #include #include +#include #include class QScreen; +class QMenu; class QQmlApplicationEngine; class QQuickWindow; class QWindow; @@ -90,6 +92,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; @@ -145,8 +148,12 @@ 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); + void showAssistantWindow(int userIndex = -1); + void showUserStatusWindow(int userIndex); void setSyncIsPaused(const bool syncIsPaused); void setIsOpen(const bool isOpen); @@ -211,10 +218,9 @@ private slots: std::unique_ptr _trayEngine; QPointer _contextMenu; - QSharedPointer _trayWindow; -#ifndef Q_OS_MACOS - QSharedPointer _popupWindow; -#endif + QHash> _activitiesWindows; + QHash> _assistantWindows; + QPointer _userStatusWindow; AccessManagerFactory _accessManagerFactory; @@ -226,6 +232,12 @@ private slots: QStringListModel _fakeActivityModel; }; +#ifndef Q_OS_MACOS +void setupQtTrayContextMenu(QMenu *menu, Systray *systray); +bool showQtTrayPopup(Systray *systray, const QRect &iconRect, Systray::WindowPosition position); +void hideQtTrayPopup(); +#endif + } // namespace OCC #endif //SYSTRAY_H 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/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; 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 deleted file mode 100644 index f058d6517b296..0000000000000 --- a/src/gui/tray/TrayAccountPopup.qml +++ /dev/null @@ -1,314 +0,0 @@ -/* - * 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 Qt5Compat.GraphicalEffects - -import Style -import com.nextcloud.desktopclient - -// Keep behavior and layout aligned with src/gui/macOS/trayaccountpopup_mac.mm. - -Window { - id: root - - 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 - - property bool _closing: false - property bool _hadFocusSinceShow: false - - onVisibleChanged: { - if (visible) { - _hadFocusSinceShow = false - } - } - - onActiveChanged: { - if (active) { - _hadFocusSinceShow = true - } else if (_hadFocusSinceShow && !_closing) { - Systray.hideWindow() - } - _closing = false - } - - Rectangle { - id: popupContainer - anchors.fill: parent - radius: Style.trayWindowRadius - color: palette.window - border.width: Style.trayWindowBorderWidth - border.color: palette.dark - clip: true - layer.enabled: true - 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: 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 - - 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 - color: accountRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } - } - - 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.enabled: true - 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: { - root._closing = true - UserModel.currentUserId = model.id - Systray.showQMLWindow() - } - } - } - - 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: 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 - color: addAccountRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Add account") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - 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: 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 - color: settingsRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Settings") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - 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: 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 - color: quitRow.hovered ? root.rowHoverColor : "transparent" - Behavior on color { ColorAnimation { duration: Style.trayAccountPopupHoverAnimationDuration } } - } - } - - contentItem: EnforcedPlainTextLabel { - text: qsTr("Quit") - font.pixelSize: Style.trayAccountPopupPrimaryFontSize - color: palette.windowText - verticalAlignment: Text.AlignVCenter - } - - onClicked: { - root._closing = true - Systray.shutdown() - } - } - - Item { - width: parent.width - height: Style.trayAccountPopupActionVerticalPadding - } - } - } -} 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 c76ed1406929a..875310d96f950 100644 --- a/src/gui/tray/asyncimageresponse.cpp +++ b/src/gui/tray/asyncimageresponse.cpp @@ -4,6 +4,7 @@ */ #include "asyncimageresponse.h" +#include "trayimageutils.h" #include #include @@ -14,6 +15,19 @@ 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); +} + +} + AsyncImageResponse::AsyncImageResponse(const QString &id, const QSize &requestedSize) { if (id.isEmpty()) { @@ -127,7 +141,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; } @@ -141,7 +155,7 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32); scaledSvg.fill("transparent"); QPainter painterForSvg(&scaledSvg); - svgRenderer.render(&painterForSvg); + svgRenderer.render(&painterForSvg, OCC::aspectFitRect(svgRenderer.defaultSize(), _requestedImageSize)); if (!_svgRecolor.isValid()) { setImageAndEmitFinished(scaledSvg); @@ -155,4 +169,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 bc1933484d906..535788f6335eb 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 new file mode 100644 index 0000000000000..af44eb09d3c77 --- /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 "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 apps = appsForUserId(userId); + if (_userId == userId && _apps == apps) { + return; + } + + _userId = userId; + if (_apps == apps) { + return; + } + + const auto oldCount = _apps.size(); + + if (!_apps.isEmpty()) { + beginRemoveRows(QModelIndex(), 0, _apps.size() - 1); + _apps.clear(); + 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()) { + return {}; + } + + const auto account = accounts.at(userId); + if (!account) { + return {}; + } + + return account->appList(); +} + +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..79337528c3e5b --- /dev/null +++ b/src/gui/tray/trayaccountappsmodel.h @@ -0,0 +1,56 @@ +/* + * 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); + + [[nodiscard]] AccountAppList appsForUserId(int userId) const; + + static TrayAccountAppsModel *_instance; + int _userId = -1; + AccountAppList _apps; +}; + +} // namespace OCC + +#endif // TRAYACCOUNTAPPSMODEL_H diff --git a/src/gui/tray/trayimageutils.h b/src/gui/tray/trayimageutils.h new file mode 100644 index 0000000000000..12844f2ac8126 --- /dev/null +++ b/src/gui/tray/trayimageutils.h @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include +#include + +namespace OCC { + +inline 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); +} + +} // namespace OCC diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index b7ea769b2880f..12bc45934a8f0 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); @@ -370,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); @@ -377,6 +536,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 +568,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 +1095,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 +1132,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 +1640,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 +1719,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 { @@ -2356,11 +2621,31 @@ 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 }); }); + 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 +2692,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 +2879,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 +2915,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 +2938,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 +2990,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 +3034,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() @@ -2841,14 +3160,8 @@ void UserAppsModel::buildAppList() } if (UserModel::instance()->appList().count() > 0) { - const auto talkApp = UserModel::instance()->currentUser()->talkApp(); const auto &allApps = UserModel::instance()->appList(); for (const auto &app : allApps) { - // Filter out Talk because we have a dedicated button for it - if (talkApp && app->id() == talkApp->id() && !UserModel::instance()->currentUser()->isNcAssistantEnabled()) { - continue; - } - beginInsertRows(QModelIndex(), rowCount(), rowCount()); _apps << app; endInsertRows(); diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index d7b42035d9f64..dfbec20efbe4f 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" @@ -61,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) @@ -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,7 +161,11 @@ class User : public QObject void hasLocalFolderChanged(); void featuredAppChanged(); void avatarChanged(); + void recentActivitiesChanged(); + void trayNotificationsChanged(); + void accountAlertChanged(); void accountStateChanged(); + void serverHasUserStatusChanged(); void statusChanged(); void desktopNotificationsAllowedChanged(); void headerColorChanged(); @@ -186,6 +197,7 @@ public slots: void slotBuildIncomingCallDialogs(const OCC::ActivityList &list); void slotRefreshNotifications(); void slotRefreshActivitiesInitial(); + void slotRefreshActivityPreview(); void slotRefreshActivities(); void slotRefresh(); void slotRefreshUserStatus(); @@ -194,6 +206,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 +264,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 +276,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 +361,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 +397,10 @@ class UserModel : public QAbstractListModel RemoveAccountTextRole, SyncStatusIconRole, SyncStatusOkRole, + RecentActivitiesRole, + AssistantEnabledRole, + TrayNotificationsRole, + AccountAlertRole, }; [[nodiscard]] AccountAppList appList() const; @@ -391,6 +413,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/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp new file mode 100644 index 0000000000000..9fcb25bd70a23 --- /dev/null +++ b/src/gui/trayaccountpopup_qt.cpp @@ -0,0 +1,915 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "systray.h" + +#include "accountmanager.h" +#include "accountstate.h" +#include "iconjob.h" +#include "theme.h" +#include "tray/trayaccountappsmodel.h" +#include "tray/trayimageutils.h" +#include "tray/usermodel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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 { + +constexpr auto fixedMenuWidth = 320; +// QMenu already reserves the icon/check column when painting; only reserve the +// remaining native chrome and submenu indicator for manual eliding. +constexpr auto menuTextReservedWidth = 40; +constexpr auto submenuIndicatorReservedWidth = 18; +constexpr auto minimumElidedTextWidth = 32; + +QPointer s_trayPopup; +QHash s_remoteAppIconCache; + +void setFixedMenuWidth(QMenu *menu) +{ + if (menu) { + menu->setFixedWidth(fixedMenuWidth); + menu->setToolTipsVisible(true); + } +} + +QSize nativeMenuIconSize(const QMenu *menu) +{ + static constexpr auto fallbackIconSize = 16; + + auto iconSize = fallbackIconSize; + if (menu && menu->style()) { + const auto styleIconSize = menu->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, menu); + if (styleIconSize > 0) { + iconSize = styleIconSize; + } + } + return QSize(iconSize, iconSize); +} + +bool isWaylandPlatform() +{ + return QGuiApplication::platformName().contains(QStringLiteral("wayland"), Qt::CaseInsensitive); +} + +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; +} + +QImage imageFromSvgIcon(const QString &iconPath, const QSize &requestedSize) +{ + auto renderer = QSvgRenderer{iconPath}; + if (!renderer.isValid()) { + 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; +} + +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; +} + +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); +} + +QIcon templateIconFromSvg(const QString &iconPath, const QSize &requestedSize, const QPalette &palette) +{ + return templateIconFromImage(imageFromSvgIcon(iconPath, requestedSize), palette); +} + +QIcon templateThemeIcon(const QString &iconName, const QSize &requestedSize, const QPalette &palette) +{ + return templateIconFromSvg(QStringLiteral(":/client/theme/%1").arg(iconName), requestedSize, palette); +} + +QIcon templateBlackThemeIcon(const QString &iconName, const QSize &requestedSize, const QPalette &palette) +{ + return templateIconFromSvg(QStringLiteral(":/client/theme/black/%1").arg(iconName), requestedSize, palette); +} + +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)); +} + +bool isRemoteIconUrl(const QUrl &url) +{ + return url.scheme() == QStringLiteral("http") || url.scheme() == QStringLiteral("https"); +} + +QString remoteIconCacheKey(const AccountStatePtr &accountState, + const QUrl &url, + const QSize &requestedSize, + const QPalette &iconPalette) +{ + if (!accountState || !accountState->account()) { + return {}; + } + return QStringLiteral("%1:%2x%3:%4:%5").arg( + accountState->account()->id(), + QString::number(requestedSize.width()), + QString::number(requestedSize.height()), + templateIconPaletteCacheKey(iconPalette), + url.toString()); +} + +void fetchRemoteAppIcon(QAction *action, + const AccountStatePtr &accountState, + const QUrl &iconUrl, + const QSize &requestedSize, + const QPalette &iconPalette) +{ + if (!action || !accountState || !accountState->account() || !isRemoteIconUrl(iconUrl)) { + return; + } + + const auto cacheKey = remoteIconCacheKey(accountState, iconUrl, requestedSize, iconPalette); + 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, requestedSize, iconPalette](const QByteArray &iconData) { + const auto image = imageFromImageData(iconData, requestedSize); + if (image.isNull()) { + return; + } + + const auto icon = templateIconFromImage(image, iconPalette); + s_remoteAppIconCache.insert(cacheKey, icon); + if (actionPointer) { + actionPointer->setIcon(icon); + } + }); +} + +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 timedMenuText(const QString &text, const QString &dateTime) +{ + if (!dateTime.isEmpty()) { + return text + QStringLiteral(" - ") + dateTime; + } + return text; +} + +int menuTextWidth(const bool hasSubmenu = false) +{ + auto textWidth = fixedMenuWidth - menuTextReservedWidth; + if (hasSubmenu) { + textWidth -= submenuIndicatorReservedWidth; + } + + return std::max(textWidth, minimumElidedTextWidth); +} + +QString elidedMenuText(const QMenu *menu, const QString &text, const bool hasSubmenu = false) +{ + if (!menu || text.isEmpty()) { + return text; + } + + return menu->fontMetrics().elidedText(text, Qt::ElideRight, menuTextWidth(hasSubmenu)); +} + +QString elidedTimedMenuText(const QMenu *menu, const QString &text, const QString &dateTime, const bool hasSubmenu = false) +{ + if (!menu || text.isEmpty() || dateTime.isEmpty()) { + return elidedMenuText(menu, timedMenuText(text, dateTime), hasSubmenu); + } + + const auto timeSuffix = QStringLiteral(" - ") + dateTime; + const auto availableTextWidth = menuTextWidth(hasSubmenu); + const auto messageTextWidth = std::max( + availableTextWidth - menu->fontMetrics().horizontalAdvance(timeSuffix), + minimumElidedTextWidth); + return menu->fontMetrics().elidedText(text, Qt::ElideRight, messageTextWidth) + timeSuffix; +} + +void setFullTextTooltip(QAction *action, const QString &fullText, const QString &displayText) +{ + if (action && fullText != displayText) { + action->setToolTip(fullText); + } +} + +QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) +{ + const auto displayText = elidedMenuText(menu, text); + const auto action = icon.isNull() ? menu->addAction(displayText) : menu->addAction(icon, displayText); + setFullTextTooltip(action, text, displayText); + return action; +} + +QAction *addTimedMenuAction(QMenu *menu, const QIcon &icon, const QString &text, const QString &dateTime) +{ + const auto fullText = timedMenuText(text, dateTime); + const auto displayText = elidedTimedMenuText(menu, text, dateTime); + const auto action = icon.isNull() ? menu->addAction(displayText) : menu->addAction(icon, displayText); + setFullTextTooltip(action, fullText, displayText); + return action; +} + +QMenu *addSubMenu(QMenu *menu, const QIcon &icon, const QString &text) +{ + const auto displayText = elidedMenuText(menu, text, true); + auto subMenu = icon.isNull() ? menu->addMenu(displayText) : menu->addMenu(icon, displayText); + if (subMenu) { + setFullTextTooltip(subMenu->menuAction(), text, displayText); + } + return subMenu; +} + +QMenu *addTimedSubMenu(QMenu *menu, const QIcon &icon, const QString &text, const QString &dateTime) +{ + const auto fullText = timedMenuText(text, dateTime); + const auto displayText = elidedTimedMenuText(menu, text, dateTime, true); + auto subMenu = icon.isNull() ? menu->addMenu(displayText) : menu->addMenu(icon, displayText); + if (subMenu) { + setFullTextTooltip(subMenu->menuAction(), fullText, displayText); + } + return subMenu; +} + +void clearDynamicMenu(QMenu *menu) +{ + if (!menu) { + return; + } + + const auto childMenus = menu->findChildren(QString{}, Qt::FindDirectChildrenOnly); + menu->clear(); + for (auto childMenu : childMenus) { + delete childMenu; + } +} + +QAction *addSectionHeading(QMenu *menu, const QString &text) +{ + const auto displayText = elidedMenuText(menu, text); + const auto action = menu->addAction(displayText); + setFullTextTooltip(action, text, displayText); + action->setEnabled(false); + action->setIconVisibleInMenu(false); + return action; +} + +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) { + 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 openSettingsAfterTrayPopupCloses() +{ + closeTrayPopup(); + QTimer::singleShot(0, Systray::instance(), [] { + Systray::instance()->openSettings(); + }); +} + +void openNotification(const int userId, const bool opensSettings) +{ + if (opensSettings) { + openSettingsAfterTrayPopupCloses(); + return; + } + closeTrayPopup(); + Systray::instance()->showActivitiesWindow(userId); +} + +bool populateAppsMenu(QMenu *menu, const int userId) +{ + setFixedMenuWidth(menu); + clearDynamicMenu(menu); + + 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{}; + const auto appIconPalette = nativeMenuIconPalette(menu); + const auto appIconSize = nativeMenuIconSize(menu); + + 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(); + const auto appIconUrl = appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl(); + auto appIcon = iconFromUrl(appIconUrl); + if (appIcon.isNull()) { + appIcon = templateBlackThemeIcon(QStringLiteral("more-apps.svg"), appIconSize, appIconPalette); + } else { + appIcon = templateIconFromIcon(appIcon, appIconSize, appIconPalette); + } + + const auto action = addMenuAction(menu, appIcon, appName); + fetchRemoteAppIcon(action, accountState, appIconUrl, appIconSize, appIconPalette); + QObject::connect(action, &QAction::triggered, action, [appUrl] { + closeTrayPopup(); + TrayAccountAppsModel::instance()->openAppUrl(appUrl); + }); + } + + if (menu->isEmpty()) { + const auto noAppsAction = addMenuAction(menu, QIcon{}, 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 = addMenuAction(menu, QIcon{}, 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; + } + + addSectionHeading(menu, 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(); + if (actions.isEmpty()) { + const auto action = addTimedMenuAction(menu, activityIcon(notificationData), title, dateTime); + QObject::connect(action, &QAction::triggered, action, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + continue; + } + + const auto notificationMenu = addTimedSubMenu(menu, activityIcon(notificationData), title, dateTime); + setFixedMenuWidth(notificationMenu); + const auto openAction = addMenuAction(notificationMenu, QIcon{}, QCoreApplication::translate("TrayAccountPopup", "Open")); + QObject::connect(openAction, &QAction::triggered, openAction, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + notificationMenu->addSeparator(); + populateNotificationActionsMenu(notificationMenu, userId, activityIndex, actions); + } + + menu->addSeparator(); +} + +void addRecentActivities(QMenu *menu, const int userId, const QVariantList &recentActivities) +{ + addSectionHeading(menu, QCoreApplication::translate("TrayAccountPopup", "Recent activity")); + + if (recentActivities.isEmpty()) { + const auto menuIconPalette = nativeMenuIconPalette(menu); + const auto menuIconSize = nativeMenuIconSize(menu); + const auto noRecentActivity = addMenuAction(menu, + templateBlackThemeIcon(QStringLiteral("activity.svg"), menuIconSize, menuIconPalette), + QCoreApplication::translate("TrayAccountPopup", "No recent activity")); + noRecentActivity->setEnabled(false); + } + + for (const auto &recentActivityVariant : recentActivities) { + const auto activityData = recentActivityVariant.toMap(); + const auto message = activityData.value(QStringLiteral("subtitle")).toString(); + if (message.isEmpty()) { + continue; + } + + const auto dateTime = activityData.value(QStringLiteral("dateTime")).toString(); + const auto action = addTimedMenuAction(menu, activityIcon(activityData), message, dateTime); + QObject::connect(action, &QAction::triggered, action, [userId] { + openActivitiesForUser(userId); + }); + } + + const auto moreActivitiesAction = addMenuAction(menu, QIcon{}, QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246")); + QObject::connect(moreActivitiesAction, &QAction::triggered, moreActivitiesAction, [userId] { + openActivitiesForUser(userId); + }); +} + +void populateAccountMenu(QMenu *menu, const int userId, const bool fetchActivityPreview = true) +{ + setFixedMenuWidth(menu); + clearDynamicMenu(menu); + + const auto userModel = UserModel::instance(); + if (!userModel || userId < 0 || userId >= userModel->rowCount()) { + return; + } + + if (fetchActivityPreview) { + userModel->fetchActivityPreview(userId); + } + + const auto userModelIndex = userModel->index(userId); + const auto menuIconPalette = nativeMenuIconPalette(menu); + const auto menuIconSize = nativeMenuIconSize(menu); + const auto serverHasUserStatus = userModel->data(userModelIndex, UserModel::ServerHasUserStatusRole).toBool(); + const auto onlineStatusEnabled = userModel->data(userModelIndex, UserModel::IsConnectedRole).toBool() && serverHasUserStatus; + + 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) { + 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, + 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, + templateBlackThemeIcon(QStringLiteral("folder.svg"), menuIconSize, menuIconPalette), + QCoreApplication::translate("TrayFoldersMenuButton", "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, + templateBlackThemeIcon(QStringLiteral("nc-assistant-app.svg"), menuIconSize, menuIconPalette), + QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246")); + QObject::connect(assistantAction, &QAction::triggered, assistantAction, [userId] { + openAssistantForUser(userId); + }); + } + + const auto appsMenu = addSubMenu(menu, + templateBlackThemeIcon(QStringLiteral("more-apps.svg"), menuIconSize, menuIconPalette), + QCoreApplication::translate("TrayWindowHeader", "More apps")); + setFixedMenuWidth(appsMenu); + 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, Systray *systray) +{ + if (!systray) { + return; + } + + setFixedMenuWidth(menu); + clearDynamicMenu(menu); + + const auto userModel = UserModel::instance(); + const auto menuIconPalette = nativeMenuIconPalette(menu); + const auto menuIconSize = nativeMenuIconSize(menu); + 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 = addSubMenu(menu, accountIcon, accountText); + setFixedMenuWidth(accountMenu); + 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->enableAddAccount()) { + const auto addAccountAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("add.svg"), menuIconSize, menuIconPalette), + Systray::tr("Add account")); + QObject::connect(addAccountAction, &QAction::triggered, addAccountAction, [] { + closeTrayPopup(); + Systray::instance()->openAccountWizard(); + }); + } + + const auto settingsAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("settings.svg"), menuIconSize, menuIconPalette), + Systray::tr("Settings")); + QObject::connect(settingsAction, &QAction::triggered, settingsAction, [] { + openSettingsAfterTrayPopupCloses(); + }); + + const auto quitAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("close.svg"), menuIconSize, menuIconPalette), + 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) +{ + 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 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); + } + + const auto trayCenter = iconRect.isValid() && !iconRect.isNull() + ? iconRect.center() + : QCursor::pos(); + auto positionPoint = QPoint(trayCenter.x() - menuSize.width() / 2, trayCenter.y()); + if (trayCenter.y() < availableGeometry.center().y()) { + positionPoint.setY(availableGeometry.top()); + } else { + positionPoint.setY(availableGeometry.bottom() - menuSize.height() + 1); + } + + return clampedMenuPosition(positionPoint, menuSize, availableGeometry); +} + +} // namespace + +void setupQtTrayContextMenu(QMenu *menu, Systray *systray) +{ + if (!menu || !systray) { + return; + } + + populateTrayMenu(menu, systray); + QObject::connect(menu, &QMenu::aboutToShow, menu, [menu, systray] { + populateTrayMenu(menu, systray); + }); +} + +bool showQtTrayPopup(Systray *systray, const QRect &iconRect, const Systray::WindowPosition position) +{ + if (!systray) { + return false; + } + + // Wayland cannot create an arbitrary QWidget popup from a tray activation. + if (isWaylandPlatform()) { + systray->showActivitiesWindow(); + return false; + } + + hideQtTrayPopup(); + + const auto menu = new QMenu(); + s_trayPopup = menu; + populateTrayMenu(menu, systray); + + QObject::connect(menu, &QMenu::aboutToHide, menu, [menu, systray] { + if (s_trayPopup == menu) { + s_trayPopup = nullptr; + } + systray->setIsOpen(false); + menu->deleteLater(); + }); + + menu->popup(trayPopupPosition(menu, iconRect, position)); + return true; +} + +void hideQtTrayPopup() +{ + if (s_trayPopup) { + s_trayPopup->close(); + } +} + +} // namespace OCC diff --git a/src/gui/userstatusselectormodel.cpp b/src/gui/userstatusselectormodel.cpp index 2a1d3b8c937c4..7c014151850ba 100644 --- a/src/gui/userstatusselectormodel.cpp +++ b/src/gui/userstatusselectormodel.cpp @@ -15,6 +15,7 @@ #include #include #include +#include namespace OCC { @@ -98,6 +99,7 @@ void UserStatusSelectorModel::reset() &UserStatusSelectorModel::onMessageCleared); } _userStatusConnector = nullptr; + _setUserStatusOperations.clear(); } void UserStatusSelectorModel::init() @@ -126,6 +128,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 +170,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 +195,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,7 +217,7 @@ void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) } _userStatus.setState(status); - _userStatusConnector->setUserStatus(_userStatus); + setUserStatus(SetUserStatusOperation::OnlineStatus); emit userStatusChanged(); } @@ -307,7 +339,20 @@ void UserStatusSelectorModel::setUserStatus() } clearError(); - _userStatusConnector->setUserStatus(_userStatus); + setUserStatus(SetUserStatusOperation::Message); +} + +void UserStatusSelectorModel::setUserStatus(SetUserStatusOperation operation) +{ + _setUserStatusOperations.push_back(operation); + if (_userStatusConnector->setUserStatus(_userStatus)) { + return; + } + + const auto queuedOperation = std::find(_setUserStatusOperations.rbegin(), _setUserStatusOperations.rend(), operation); + if (queuedOperation != _setUserStatusOperations.rend()) { + _setUserStatusOperations.erase(std::next(queuedOperation).base()); + } } void UserStatusSelectorModel::clearUserStatus() diff --git a/src/gui/userstatusselectormodel.h b/src/gui/userstatusselectormodel.h index 424925f555b12..8ba5e6d91bdb5 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); @@ -123,12 +133,15 @@ public slots: [[nodiscard]] Optional clearStageTypeToDateTime(ClearStageType type) const; void setError(const QString &reason); void clearError(); + void setUserStatus(SetUserStatusOperation operation); 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/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/src/libsync/ocsuserstatusconnector.cpp b/src/libsync/ocsuserstatusconnector.cpp index a70088034e3da..62210c46b81dd 100644 --- a/src/libsync/ocsuserstatusconnector.cpp +++ b/src/libsync/ocsuserstatusconnector.cpp @@ -391,22 +391,23 @@ void OcsUserStatusConnector::setUserStatusMessage(const UserStatus &userStatus) setUserStatusMessageCustom(userStatus); } -void OcsUserStatusConnector::setUserStatus(const UserStatus &userStatus) +bool OcsUserStatusConnector::setUserStatus(const UserStatus &userStatus) { if (!_userStatusSupported) { emit error(Error::UserStatusNotSupported); - return; + return false; } if (_setOnlineStatusJob || _setMessageJob) { qCDebug(lcOcsUserStatusConnector) << "Set online status job or set message job are already running."; - return; + return false; } if (userStatus.state() != _userStatus.state()) { setUserStatusOnlineStatus(userStatus.state()); } setUserStatusMessage(userStatus); + return true; } void OcsUserStatusConnector::onUserStatusOnlineStatusSet(const QJsonDocument &json, int statusCode) diff --git a/src/libsync/ocsuserstatusconnector.h b/src/libsync/ocsuserstatusconnector.h index 77e3b69f6ea02..3724f4b5c4b6d 100644 --- a/src/libsync/ocsuserstatusconnector.h +++ b/src/libsync/ocsuserstatusconnector.h @@ -24,7 +24,7 @@ class OWNCLOUDSYNC_EXPORT OcsUserStatusConnector : public UserStatusConnector void fetchPredefinedStatuses() override; - void setUserStatus(const UserStatus &userStatus) override; + [[nodiscard]] bool setUserStatus(const UserStatus &userStatus) override; void clearMessage() override; diff --git a/src/libsync/userstatusconnector.h b/src/libsync/userstatusconnector.h index 4fe0b5732e655..ff8b1c846d00e 100644 --- a/src/libsync/userstatusconnector.h +++ b/src/libsync/userstatusconnector.h @@ -113,7 +113,8 @@ class OWNCLOUDSYNC_EXPORT UserStatusConnector : public QObject virtual void fetchPredefinedStatuses() = 0; - virtual void setUserStatus(const UserStatus &userStatus) = 0; + // Returns false if the request was not accepted, for example while another set request is still running. + [[nodiscard]] virtual bool setUserStatus(const UserStatus &userStatus) = 0; virtual void clearMessage() = 0; diff --git a/test/testsetuserstatusdialog.cpp b/test/testsetuserstatusdialog.cpp index 92512fa676cd7..faf7e4fecd486 100644 --- a/test/testsetuserstatusdialog.cpp +++ b/test/testsetuserstatusdialog.cpp @@ -42,15 +42,23 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector emit predefinedStatusesFetched(_predefinedStatuses); } - void setUserStatus(const OCC::UserStatus &userStatus) override + [[nodiscard]] bool setUserStatus(const OCC::UserStatus &userStatus) override { + ++_setUserStatusCallCount; + if (_rejectUserStatusChanges) { + return false; + } + if (_couldNotSetUserStatusMessage) { emit error(Error::CouldNotSetUserStatus); - return; + return false; } _userStatusSetByCallerOfSetUserStatus = userStatus; - emit UserStatusConnector::userStatusSet(); + if (!_deferUserStatusSetSignal) { + emit UserStatusConnector::userStatusSet(); + } + return true; } void clearMessage() override @@ -59,6 +67,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector emit error(Error::CouldNotClearMessage); } else { _isMessageCleared = true; + emit UserStatusConnector::messageCleared(); } } @@ -84,9 +93,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; @@ -117,10 +132,21 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector _couldNotClearUserStatusMessage = value; } + void setRejectUserStatusChanges(bool value) + { + _rejectUserStatusChanges = value; + } + + void setDeferUserStatusSetSignal(bool value) + { + _deferUserStatusSetSignal = value; + } + private: OCC::UserStatus _userStatusSetByCallerOfSetUserStatus; OCC::UserStatus _userStatus; QVector _predefinedStatuses; + int _setUserStatusCallCount = 0; bool _isMessageCleared = false; bool _couldNotFetchPredefinedUserStatuses = false; bool _couldNotFetchUserStatus = false; @@ -128,6 +154,8 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector bool _userStatusNotSupported = false; bool _emojisNotSupported = false; bool _couldNotClearUserStatusMessage = false; + bool _rejectUserStatusChanges = false; + bool _deferUserStatusSetSignal = false; }; class FakeDateTimeProvider : public OCC::DateTimeProvider @@ -277,6 +305,114 @@ 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_rejectedSaveAfterOnlineStatusDoesNotFinishOnOnlineStatusCompletion() + { + auto fakeUserStatusJob = std::make_shared(); + fakeUserStatusJob->setDeferUserStatusSetSignal(true); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + fakeUserStatusJob->setRejectUserStatusChanges(true); + model.setUserStatusMessage(QStringLiteral("Some status")); + model.setUserStatus(); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 2); + fakeUserStatusJob->emitUserStatusSet(); + fakeUserStatusJob->emitUserStatusSet(); + + QCOMPARE(finishedSpy.count(), 0); + + fakeUserStatusJob->setRejectUserStatusChanges(false); + fakeUserStatusJob->setDeferUserStatusSetSignal(false); + model.setUserStatus(); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 3); + QCOMPARE(finishedSpy.count(), 1); + } + + 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(); diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 33f212649c267..b333e037b7c09 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -81,15 +81,21 @@ QtObject { property int trayModalWidth: 380 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) + 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 +119,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 1d6b37b2a331d521d827cf1e6a4aa88ed1ff8122 Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 6 Jul 2026 20:55:50 +0200 Subject: [PATCH 02/19] fix: Code cleanups and Claude-Review findings Signed-off-by: Rello --- src/gui/ActivitiesWindow.qml | 1 + src/gui/tray/ActivityItem.qml | 17 ++++++++++++++--- src/gui/tray/ActivityItemContent.qml | 10 ++++++++-- src/gui/tray/ActivityList.qml | 3 +++ src/gui/tray/MainWindow.qml | 1 + src/gui/tray/TalkReplyTextField.qml | 5 +++-- src/gui/tray/usermodel.cpp | 11 ++++++++--- src/gui/wizard/qml/AccountWizardWindow.qml | 10 ++++++++++ src/gui/wizard/qml/WizardButton.qml | 8 ++++++++ 9 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/gui/ActivitiesWindow.qml b/src/gui/ActivitiesWindow.qml index a934fd623f77e..18b1e916dac35 100644 --- a/src/gui/ActivitiesWindow.qml +++ b/src/gui/ActivitiesWindow.qml @@ -107,6 +107,7 @@ ApplicationWindow { anchors.fill: parent activeFocusOnTab: true + currentUser: root.currentUser model: root.activityModel onOpenFile: Qt.openUrlExternally(filePath) onActivityItemClicked: { diff --git a/src/gui/tray/ActivityItem.qml b/src/gui/tray/ActivityItem.qml index 6af9ec3c16522..58f94f229c4d9 100644 --- a/src/gui/tray/ActivityItem.qml +++ b/src/gui/tray/ActivityItem.qml @@ -17,10 +17,14 @@ ItemDelegate { property int iconSize: Style.trayListItemIconSize + property var currentUser: UserModel.currentUser + + property var activityModel: null + property bool isFileActivityList: false readonly property bool isChatActivity: model.objectType === "chat" || model.objectType === "room" || model.objectType === "call" - readonly property bool isTalkReplyPossible: model.conversationToken !== "" + readonly property bool isTalkReplyPossible: root.currentUser !== null && model.conversationToken !== "" property bool isTalkReplyOptionVisible: model.messageSent !== "" padding: Style.standardSpacing @@ -55,7 +59,13 @@ ItemDelegate { activityData: model activity: model.activity - onDismissButtonClicked: activityModel.slotTriggerDismiss(model.activityIndex) + activityModel: root.activityModel + + onDismissButtonClicked: { + if (root.activityModel) { + root.activityModel.slotTriggerDismiss(model.activityIndex) + } + } } Loader { @@ -68,8 +78,9 @@ ItemDelegate { Layout.leftMargin: Style.trayListItemIconSize + Style.trayHorizontalMargin sourceComponent: TalkReplyTextField { + accentColor: root.currentUser ? root.currentUser.accentColor : palette.highlight onSendReply: { - UserModel.currentUser.sendReplyMessage(model.activityIndex, model.conversationToken, reply, model.messageId); + root.currentUser.sendReplyMessage(model.activityIndex, model.conversationToken, reply, model.messageId); talkReplyTextFieldLoader.visible = false; } } diff --git a/src/gui/tray/ActivityItemContent.qml b/src/gui/tray/ActivityItemContent.qml index fbea2354845b2..fc0779df1b55b 100644 --- a/src/gui/tray/ActivityItemContent.qml +++ b/src/gui/tray/ActivityItemContent.qml @@ -20,6 +20,8 @@ RowLayout { property variant activity: {{}} + property var activityModel: null + property bool showDismissButton: false property bool childHovered: fileDetailsButton.hovered || dismissActionButton.hovered @@ -297,9 +299,13 @@ RowLayout { linksForActionButtons: activityData.linksForActionButtons linksContextMenu: activityData.linksContextMenu - maxActionButtons: activityModel.maxActionButtons + maxActionButtons: root.activityModel ? root.activityModel.maxActionButtons : 0 - onTriggerAction: activityModel.slotTriggerAction(activityData.activityIndex, actionIndex) + onTriggerAction: { + if (root.activityModel) { + root.activityModel.slotTriggerAction(activityData.activityIndex, actionIndex) + } + } onShowReplyField: isTalkReplyOptionVisible = true talkReplyButtonVisible: root.activityData.messageSent === "" && !isTalkReplyOptionVisible diff --git a/src/gui/tray/ActivityList.qml b/src/gui/tray/ActivityList.qml index e2e43c3492707..0d8f009c6ae00 100644 --- a/src/gui/tray/ActivityList.qml +++ b/src/gui/tray/ActivityList.qml @@ -12,6 +12,7 @@ import com.nextcloud.desktopclient as NC ScrollView { id: controlRoot property alias model: sortedActivityList.sourceModel + property var currentUser: NC.UserModel.currentUser property alias count: activityList.count property alias atYBeginning : activityList.atYBeginning property bool isFileActivityList: false @@ -98,6 +99,8 @@ ScrollView { width: activityList.contentItem.width isFileActivityList: controlRoot.isFileActivityList + currentUser: controlRoot.currentUser + activityModel: controlRoot.model iconSize: controlRoot.iconSize flickable: activityList onHoveredChanged: if (hovered) { diff --git a/src/gui/tray/MainWindow.qml b/src/gui/tray/MainWindow.qml index a0805cf55fc06..5dc87cc3d8487 100644 --- a/src/gui/tray/MainWindow.qml +++ b/src/gui/tray/MainWindow.qml @@ -878,6 +878,7 @@ ApplicationWindow { anchors.bottom: trayWindowMainItem.bottom activeFocusOnTab: true + currentUser: UserModel.currentUser model: activityModel onOpenFile: Qt.openUrlExternally(filePath); onActivityItemClicked: { diff --git a/src/gui/tray/TalkReplyTextField.qml b/src/gui/tray/TalkReplyTextField.qml index 28e3883633957..1c61a3ba2b785 100644 --- a/src/gui/tray/TalkReplyTextField.qml +++ b/src/gui/tray/TalkReplyTextField.qml @@ -13,6 +13,8 @@ import Style TextField { id: replyMessageTextField + property color accentColor: UserModel.currentUser ? UserModel.currentUser.accentColor : palette.highlight + signal sendReply(string reply) function sendReplyMessage() { if (text !== "") sendReply(text) } @@ -43,7 +45,7 @@ TextField { icon { source: "image://svgimage-custom-color/send.svg" + "/" + palette.dark - color: hovered || !sendReplyMessageButton.enabled ? palette.dark : UserModel.currentUser.accentColor + color: hovered || !sendReplyMessageButton.enabled ? palette.dark : replyMessageTextField.accentColor } anchors { @@ -58,4 +60,3 @@ TextField { } } } - diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 12bc45934a8f0..04ad656affb29 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -223,9 +223,12 @@ SyncStatusInfo syncStatusForAccount(const OCC::AccountStatePtr &accountState) hasError = true; break; case OCC::SyncResult::Problem: - case OCC::SyncResult::Undefined: hasWarning = true; break; + case OCC::SyncResult::Undefined: + // The extension has not reported a concrete state yet. Keep the account row neutral + // until a real File Provider status arrives. + break; case OCC::SyncResult::Paused: hasPaused = true; break; @@ -379,7 +382,7 @@ bool accountHasSyncErrors(const OCC::AccountStatePtr &accountState) #ifdef BUILD_FILE_PROVIDER_MODULE const auto fileProviderStatus = OCC::Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(accountState->account()); - if (isSyncStatusError(fileProviderStatus)) { + if (fileProviderStatus != OCC::SyncResult::Undefined && isSyncStatusError(fileProviderStatus)) { return true; } #endif @@ -406,7 +409,9 @@ SyncIssueKind syncIssueKindForAccount(const OCC::AccountStatePtr &accountState) #ifdef BUILD_FILE_PROVIDER_MODULE const auto fileProviderStatus = OCC::Mac::FileProvider::instance()->service()->latestReceivedSyncStatusForAccount(accountState->account()); - result = strongestSyncIssueKind(result, syncIssueKindForSyncResult(fileProviderStatus)); + if (fileProviderStatus != OCC::SyncResult::Undefined) { + result = strongestSyncIssueKind(result, syncIssueKindForSyncResult(fileProviderStatus)); + } #endif return result; diff --git a/src/gui/wizard/qml/AccountWizardWindow.qml b/src/gui/wizard/qml/AccountWizardWindow.qml index b76f192c948cb..b1f8597d7f883 100644 --- a/src/gui/wizard/qml/AccountWizardWindow.qml +++ b/src/gui/wizard/qml/AccountWizardWindow.qml @@ -320,6 +320,8 @@ ApplicationWindow { }, Button { + id: proxySettingsButton + visible: root.controller && root.controller.currentStep === AccountWizardController.ServerStep && root.controller.proxySettingsAvailable @@ -332,6 +334,14 @@ ApplicationWindow { Layout.preferredWidth: 1 Layout.preferredHeight: 36 onClicked: root.controller.openProxySettings() + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + enabled: proxySettingsButton.enabled + hoverEnabled: enabled + cursorShape: Qt.PointingHandCursor + } }, Item { diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index 162b753967bd1..d4367fe2031ac 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -91,4 +91,12 @@ BasicControls.Button { : root.secondaryColor } } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + enabled: root.enabled + hoverEnabled: enabled + cursorShape: Qt.PointingHandCursor + } } From a5f1a5dd367dd6ec39c15f7eea5f870759630a19 Mon Sep 17 00:00:00 2001 From: Rello Date: Mon, 6 Jul 2026 21:50:13 +0200 Subject: [PATCH 03/19] fix: test case issue Signed-off-by: Rello --- src/gui/UserStatusMessageView.qml | 21 +++++++---- src/gui/UserStatusSetStatusView.qml | 19 ++++++---- src/gui/UserStatusWindow.qml | 44 ++++++++++++++++++----- src/gui/systray.cpp | 7 +++- src/gui/systray.h | 2 ++ src/gui/trayaccountpopup_qt.cpp | 9 +++++ src/gui/userstatusselectormodel.cpp | 56 +++++++++++++++++++++++++++-- src/gui/userstatusselectormodel.h | 5 +++ test/testactivitylistmodel.cpp | 16 +++++---- test/testsetuserstatusdialog.cpp | 34 ++++++++++++++++++ 10 files changed, 183 insertions(+), 30 deletions(-) diff --git a/src/gui/UserStatusMessageView.qml b/src/gui/UserStatusMessageView.qml index 965f2fe0e31b3..be0c855c8f728 100644 --- a/src/gui/UserStatusMessageView.qml +++ b/src/gui/UserStatusMessageView.qml @@ -18,6 +18,8 @@ ColumnLayout { id: rootLayout property NC.UserStatusSelectorModel userStatusSelectorModel + readonly property bool userStatusLoaded: userStatusSelectorModel !== null + && userStatusSelectorModel.userStatusLoaded signal finished @@ -58,6 +60,7 @@ ColumnLayout { text: userStatusSelectorModel && userStatusSelectorModel.userStatusEmoji.length > 0 ? userStatusSelectorModel.userStatusEmoji : fallbackEmoji + enabled: rootLayout.userStatusLoaded padding: 0 z: showBorder ? 2 : 0 hoverEnabled: true @@ -101,7 +104,7 @@ ColumnLayout { id: emojiPicker onChosen: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.userStatusEmoji = emoji } emojiDialog.close() @@ -115,9 +118,10 @@ ColumnLayout { Layout.fillWidth: true placeholderText: qsTr("What is your status?") text: userStatusSelectorModel ? userStatusSelectorModel.userStatusMessage : "" + enabled: rootLayout.userStatusLoaded selectByMouse: true onEditingFinished: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.userStatusMessage = text } } @@ -134,6 +138,7 @@ ColumnLayout { ListView { spacing: 0 + enabled: rootLayout.userStatusLoaded model: userStatusSelectorModel ? userStatusSelectorModel.predefinedStatuses : [] delegate: PredefinedStatusButton { anchors.left: parent.left @@ -145,8 +150,9 @@ ColumnLayout { emoji: modelData.icon statusText: modelData.message clearAtText: userStatusSelectorModel ? userStatusSelectorModel.clearAtReadable(modelData) : "" + enabled: rootLayout.userStatusLoaded onClicked: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.setPredefinedStatus(modelData) } } @@ -180,8 +186,9 @@ ColumnLayout { textRole: "display" valueRole: "clearStageType" displayText: userStatusSelectorModel ? userStatusSelectorModel.clearAtDisplayString : "" + enabled: rootLayout.userStatusLoaded onActivated: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.setClearAt(currentValue) } } @@ -210,8 +217,9 @@ ColumnLayout { } Button { text: qsTr("Clear") + enabled: rootLayout.userStatusLoaded onClicked: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.clearUserStatus() } } @@ -219,8 +227,9 @@ ColumnLayout { Button { focusPolicy: Qt.StrongFocus text: qsTr("Apply") + enabled: rootLayout.userStatusLoaded onClicked: { - if (userStatusSelectorModel) { + if (rootLayout.userStatusLoaded) { userStatusSelectorModel.setUserStatus() } } diff --git a/src/gui/UserStatusSetStatusView.qml b/src/gui/UserStatusSetStatusView.qml index abff60d1e16a5..81c716979b123 100644 --- a/src/gui/UserStatusSetStatusView.qml +++ b/src/gui/UserStatusSetStatusView.qml @@ -16,6 +16,8 @@ ColumnLayout { id: root property NC.UserStatusSelectorModel userStatusSelectorModel + readonly property bool userStatusLoaded: userStatusSelectorModel !== null + && userStatusSelectorModel.userStatusLoaded signal finished signal showStatusMessageRequested @@ -24,7 +26,7 @@ ColumnLayout { Layout.fillWidth: true function handleStatusClick(status) { - if (!userStatusSelectorModel) { + if (!root.userStatusLoaded) { return; } @@ -44,7 +46,8 @@ ColumnLayout { } UserStatusSelectorButton { - checked: userStatusSelectorModel && userStatusSelectorModel.onlineStatus === NC.userStatus.Online + enabled: root.userStatusLoaded + checked: root.userStatusLoaded && userStatusSelectorModel.onlineStatus === NC.userStatus.Online checkable: true icon.source: userStatusSelectorModel ? userStatusSelectorModel.onlineIcon : "" icon.color: "transparent" @@ -54,7 +57,8 @@ ColumnLayout { } UserStatusSelectorButton { - checked: userStatusSelectorModel && userStatusSelectorModel.onlineStatus === NC.userStatus.Away + enabled: root.userStatusLoaded + checked: root.userStatusLoaded && userStatusSelectorModel.onlineStatus === NC.userStatus.Away checkable: true icon.source: userStatusSelectorModel ? userStatusSelectorModel.awayIcon : "" icon.color: "transparent" @@ -65,7 +69,8 @@ ColumnLayout { UserStatusSelectorButton { visible: userStatusSelectorModel && userStatusSelectorModel.busyStatusSupported - checked: userStatusSelectorModel && userStatusSelectorModel.onlineStatus === NC.userStatus.Busy + enabled: root.userStatusLoaded + checked: root.userStatusLoaded && userStatusSelectorModel.onlineStatus === NC.userStatus.Busy checkable: true icon.source: userStatusSelectorModel ? userStatusSelectorModel.busyIcon : "" icon.color: "transparent" @@ -75,7 +80,8 @@ ColumnLayout { } UserStatusSelectorButton { - checked: userStatusSelectorModel && userStatusSelectorModel.onlineStatus === NC.userStatus.DoNotDisturb + enabled: root.userStatusLoaded + checked: root.userStatusLoaded && userStatusSelectorModel.onlineStatus === NC.userStatus.DoNotDisturb checkable: true icon.source: userStatusSelectorModel ? userStatusSelectorModel.dndIcon : "" icon.color: "transparent" @@ -86,7 +92,8 @@ ColumnLayout { } UserStatusSelectorButton { - checked: userStatusSelectorModel && (userStatusSelectorModel.onlineStatus === NC.userStatus.Invisible + enabled: root.userStatusLoaded + checked: root.userStatusLoaded && (userStatusSelectorModel.onlineStatus === NC.userStatus.Invisible || userStatusSelectorModel.onlineStatus === NC.userStatus.Offline) checkable: true icon.source: userStatusSelectorModel ? userStatusSelectorModel.invisibleIcon : "" diff --git a/src/gui/UserStatusWindow.qml b/src/gui/UserStatusWindow.qml index 8c95ae46a2ae3..75021c3839c3a 100644 --- a/src/gui/UserStatusWindow.qml +++ b/src/gui/UserStatusWindow.qml @@ -55,6 +55,8 @@ ApplicationWindow { onFinished: root.close() } + readonly property bool statusLoaded: statusModel.userStatusLoaded + Binding { target: statusModel property: "userIndex" @@ -63,12 +65,20 @@ ApplicationWindow { } function setOnlineStatus(status) { + if (!root.statusLoaded) { + return + } + if (statusModel.onlineStatus !== status) { statusModel.onlineStatus = status } } function saveStatusMessage() { + if (!root.statusLoaded) { + return + } + if (statusModel.userStatusMessage !== statusMessageField.text) { statusModel.userStatusMessage = statusMessageField.text } @@ -84,7 +94,7 @@ ApplicationWindow { target: statusModel function onUserStatusChanged() { - if (!statusMessageField.activeFocus) { + if (!statusMessageField.activeFocus || !root.statusLoaded) { statusMessageField.text = statusModel.userStatusMessage } } @@ -106,7 +116,8 @@ ApplicationWindow { UserStatusWindowStatusRow { Layout.fillWidth: true - selected: statusModel.onlineStatus === NC.userStatus.Online + enabled: root.statusLoaded + selected: root.statusLoaded && statusModel.onlineStatus === NC.userStatus.Online iconSource: statusModel.onlineIcon text: qsTr("Online") onClicked: root.setOnlineStatus(NC.userStatus.Online) @@ -114,7 +125,8 @@ ApplicationWindow { UserStatusWindowStatusRow { Layout.fillWidth: true - selected: statusModel.onlineStatus === NC.userStatus.Away + enabled: root.statusLoaded + selected: root.statusLoaded && statusModel.onlineStatus === NC.userStatus.Away iconSource: statusModel.awayIcon text: qsTr("Away") onClicked: root.setOnlineStatus(NC.userStatus.Away) @@ -123,7 +135,8 @@ ApplicationWindow { UserStatusWindowStatusRow { Layout.fillWidth: true visible: statusModel.busyStatusSupported - selected: statusModel.onlineStatus === NC.userStatus.Busy + enabled: root.statusLoaded + selected: root.statusLoaded && statusModel.onlineStatus === NC.userStatus.Busy iconSource: statusModel.busyIcon text: qsTr("Busy") onClicked: root.setOnlineStatus(NC.userStatus.Busy) @@ -131,7 +144,8 @@ ApplicationWindow { UserStatusWindowStatusRow { Layout.fillWidth: true - selected: statusModel.onlineStatus === NC.userStatus.DoNotDisturb + enabled: root.statusLoaded + selected: root.statusLoaded && statusModel.onlineStatus === NC.userStatus.DoNotDisturb iconSource: statusModel.dndIcon text: qsTr("Do not disturb") secondaryText: qsTr("Mute all notifications") @@ -140,8 +154,10 @@ ApplicationWindow { UserStatusWindowStatusRow { Layout.fillWidth: true - selected: statusModel.onlineStatus === NC.userStatus.Invisible + enabled: root.statusLoaded + selected: root.statusLoaded && (statusModel.onlineStatus === NC.userStatus.Invisible || statusModel.onlineStatus === NC.userStatus.Offline + ) iconSource: statusModel.invisibleIcon text: qsTr("Invisible") secondaryText: qsTr("Appear offline") @@ -181,6 +197,7 @@ ApplicationWindow { Layout.preferredWidth: 36 Layout.preferredHeight: 36 padding: 0 + enabled: root.statusLoaded text: statusModel.userStatusEmoji.length > 0 ? statusModel.userStatusEmoji : fallbackEmoji @@ -208,9 +225,14 @@ ApplicationWindow { Layout.fillWidth: true Layout.preferredHeight: 36 placeholderText: qsTr("What is your status?") + enabled: root.statusLoaded selectByMouse: true Component.onCompleted: text = statusModel.userStatusMessage - onEditingFinished: statusModel.userStatusMessage = text + onEditingFinished: { + if (root.statusLoaded) { + statusModel.userStatusMessage = text + } + } } } @@ -241,7 +263,9 @@ ApplicationWindow { visibleRows: 10 onChosen: { - statusModel.userStatusEmoji = emoji + if (root.statusLoaded) { + statusModel.userStatusEmoji = emoji + } emojiPopup.close() } } @@ -269,6 +293,7 @@ ApplicationWindow { clearAtText: statusModel.clearAtReadable(modelData) selected: statusModel.userStatusMessage === modelData.message && statusModel.userStatusEmoji === modelData.icon + enabled: root.statusLoaded onClicked: { statusModel.setPredefinedStatus(modelData) statusMessageField.text = statusModel.userStatusMessage @@ -307,6 +332,7 @@ ApplicationWindow { valueRole: "clearStageType" displayText: statusModel.clearAtDisplayString Accessible.name: qsTr("Clear status after") + enabled: root.statusLoaded onActivated: statusModel.setClearAt(currentValue) contentItem: Text { @@ -338,6 +364,7 @@ ApplicationWindow { footer: [ WizardButton { text: qsTr("Clear status message") + enabled: root.statusLoaded onClicked: statusModel.clearUserStatus() }, @@ -348,6 +375,7 @@ ApplicationWindow { WizardButton { primary: true text: qsTr("Set status message") + enabled: root.statusLoaded onClicked: root.saveStatusMessage() } ] diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index f813fede30cdd..bb9befdd4cc80 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -937,7 +937,7 @@ bool Systray::isOpen() const bool Systray::isActivitySurfaceVisible() const { - if (isOpen()) { + if (isOpen() || _isTrayContextMenuVisible) { return true; } @@ -950,6 +950,11 @@ bool Systray::isActivitySurfaceVisible() const return false; } +void Systray::setTrayContextMenuVisible(const bool visible) +{ + _isTrayContextMenuVisible = visible; +} + bool Systray::enableAddAccount() const { #if defined ENFORCE_SINGLE_ACCOUNT diff --git a/src/gui/systray.h b/src/gui/systray.h index 28e7de333cc4c..196f892c630d4 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -93,6 +93,7 @@ class Systray : public QSystemTrayIcon [[nodiscard]] bool anySyncFolders() const; [[nodiscard]] bool isOpen() const; [[nodiscard]] bool isActivitySurfaceVisible() const; + void setTrayContextMenuVisible(const bool visible); [[nodiscard]] bool enableAddAccount() const; @@ -213,6 +214,7 @@ private slots: [[nodiscard]] QPoint computeNotificationPosition(int width, int height, int spacing = 20, NotificationPosition position = NotificationPosition::Default) const; bool _isOpen = false; + bool _isTrayContextMenuVisible = false; bool _syncIsPaused = true; bool _anySyncFolders = false; diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 9fcb25bd70a23..5c1cd8e0596c8 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -870,9 +870,18 @@ void setupQtTrayContextMenu(QMenu *menu, Systray *systray) } populateTrayMenu(menu, systray); + QObject::connect(menu, &QMenu::aboutToShow, systray, [systray] { + systray->setTrayContextMenuVisible(true); + }); QObject::connect(menu, &QMenu::aboutToShow, menu, [menu, systray] { populateTrayMenu(menu, systray); }); + QObject::connect(menu, &QMenu::aboutToHide, systray, [systray] { + systray->setTrayContextMenuVisible(false); + }); + QObject::connect(menu, &QObject::destroyed, systray, [systray] { + systray->setTrayContextMenuVisible(false); + }); } bool showQtTrayPopup(Systray *systray, const QRect &iconRect, const Systray::WindowPosition position) diff --git a/src/gui/userstatusselectormodel.cpp b/src/gui/userstatusselectormodel.cpp index 7c014151850ba..c9637aafe4752 100644 --- a/src/gui/userstatusselectormodel.cpp +++ b/src/gui/userstatusselectormodel.cpp @@ -100,6 +100,16 @@ void UserStatusSelectorModel::reset() } _userStatusConnector = nullptr; _setUserStatusOperations.clear(); + _userStatus = {}; + _predefinedStatuses.clear(); + _errorMessage.clear(); + + setUserStatusLoaded(false); + emit userStatusChanged(); + emit clearAtDisplayStringChanged(); + emit predefinedStatusesChanged(); + emit errorMessageChanged(); + emit busyStatusSupportedChanged(); } void UserStatusSelectorModel::init() @@ -158,10 +168,18 @@ void UserStatusSelectorModel::onError(UserStatusConnector::Error error) return; case UserStatusConnector::Error::CouldNotFetchUserStatus: + _userStatus = {}; + setUserStatusLoaded(false); + emit userStatusChanged(); + emit clearAtDisplayStringChanged(); setError(tr("Could not fetch status. Make sure you are connected to the server.")); return; case UserStatusConnector::Error::UserStatusNotSupported: + _userStatus = {}; + setUserStatusLoaded(false); + emit userStatusChanged(); + emit clearAtDisplayStringChanged(); setError(tr("Status feature is not supported. You will not be able to set your status.")); return; @@ -200,6 +218,21 @@ bool UserStatusSelectorModel::finishOnOnlineStatusSet() const return _finishOnOnlineStatusSet; } +bool UserStatusSelectorModel::userStatusLoaded() const +{ + return _userStatusLoaded; +} + +void UserStatusSelectorModel::setUserStatusLoaded(bool userStatusLoaded) +{ + if (_userStatusLoaded == userStatusLoaded) { + return; + } + + _userStatusLoaded = userStatusLoaded; + emit userStatusLoadedChanged(); +} + void UserStatusSelectorModel::setFinishOnOnlineStatusSet(bool finishOnOnlineStatusSet) { if (_finishOnOnlineStatusSet == finishOnOnlineStatusSet) { @@ -212,7 +245,7 @@ void UserStatusSelectorModel::setFinishOnOnlineStatusSet(bool finishOnOnlineStat void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) { - if (!_userStatusConnector || status == _userStatus.state()) { + if (!_userStatusLoaded || !_userStatusConnector || status == _userStatus.state()) { return; } @@ -255,6 +288,10 @@ QString UserStatusSelectorModel::userStatusMessage() const void UserStatusSelectorModel::setUserStatusMessage(const QString &message) { + if (!_userStatusLoaded) { + return; + } + _userStatus.setMessage(message); _userStatus.setMessagePredefined(false); emit userStatusChanged(); @@ -262,6 +299,10 @@ void UserStatusSelectorModel::setUserStatusMessage(const QString &message) void UserStatusSelectorModel::setUserStatusEmoji(const QString &emoji) { + if (!_userStatusLoaded) { + return; + } + _userStatus.setIcon(emoji); _userStatus.setMessagePredefined(false); emit userStatusChanged(); @@ -284,6 +325,7 @@ void UserStatusSelectorModel::onUserStatusFetched(const UserStatus &userStatus) emit userStatusChanged(); emit clearAtDisplayStringChanged(); + setUserStatusLoaded(true); } Optional UserStatusSelectorModel::clearStageTypeToDateTime(ClearStageType type) const @@ -334,7 +376,7 @@ Optional UserStatusSelectorModel::clearStageTypeToDateTime(ClearStageTy void UserStatusSelectorModel::setUserStatus() { - if (!_userStatusConnector) { + if (!_userStatusLoaded || !_userStatusConnector) { return; } @@ -357,7 +399,7 @@ void UserStatusSelectorModel::setUserStatus(SetUserStatusOperation operation) void UserStatusSelectorModel::clearUserStatus() { - if (!_userStatusConnector) { + if (!_userStatusLoaded || !_userStatusConnector) { return; } @@ -378,6 +420,10 @@ QVector UserStatusSelectorModel::predefinedStatuses() const void UserStatusSelectorModel::setPredefinedStatus(const UserStatus &predefinedStatus) { + if (!_userStatusLoaded) { + return; + } + _userStatus.setMessagePredefined(true); _userStatus.setId(predefinedStatus.id()); _userStatus.setMessage(predefinedStatus.message()); @@ -430,6 +476,10 @@ QVariantList UserStatusSelectorModel::clearStageTypes() const void UserStatusSelectorModel::setClearAt(const ClearStageType clearStageType) { + if (!_userStatusLoaded) { + return; + } + _userStatus.setClearAt(clearStageTypeToDateTime(clearStageType)); emit clearAtDisplayStringChanged(); } diff --git a/src/gui/userstatusselectormodel.h b/src/gui/userstatusselectormodel.h index 8ba5e6d91bdb5..7aaf0ccf74ba8 100644 --- a/src/gui/userstatusselectormodel.h +++ b/src/gui/userstatusselectormodel.h @@ -35,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 userStatusLoaded READ userStatusLoaded NOTIFY userStatusLoadedChanged) Q_PROPERTY(bool finishOnOnlineStatusSet READ finishOnOnlineStatusSet WRITE setFinishOnOnlineStatusSet NOTIFY finishOnOnlineStatusSetChanged) Q_PROPERTY(QUrl onlineIcon READ onlineIcon CONSTANT) Q_PROPERTY(QUrl awayIcon READ awayIcon CONSTANT) @@ -93,6 +94,7 @@ class UserStatusSelectorModel : public QObject [[nodiscard]] QString errorMessage() const; [[nodiscard]] bool busyStatusSupported() const; + [[nodiscard]] bool userStatusLoaded() const; [[nodiscard]] bool finishOnOnlineStatusSet() const; public slots: @@ -107,6 +109,7 @@ public slots: void userIndexChanged(); void errorMessageChanged(); void busyStatusSupportedChanged(); + void userStatusLoadedChanged(); void userStatusChanged(); void clearAtDisplayStringChanged(); void predefinedStatusesChanged(); @@ -133,6 +136,7 @@ public slots: [[nodiscard]] Optional clearStageTypeToDateTime(ClearStageType type) const; void setError(const QString &reason); void clearError(); + void setUserStatusLoaded(bool userStatusLoaded); void setUserStatus(SetUserStatusOperation operation); int _userIndex = -1; @@ -140,6 +144,7 @@ public slots: std::shared_ptr _userStatusConnector {}; QVector _predefinedStatuses; UserStatus _userStatus; + bool _userStatusLoaded = false; std::unique_ptr _dateTimeProvider; std::deque _setUserStatusOperations; diff --git a/test/testactivitylistmodel.cpp b/test/testactivitylistmodel.cpp index cfe30e908aea6..a451f44c34314 100644 --- a/test/testactivitylistmodel.cpp +++ b/test/testactivitylistmodel.cpp @@ -309,9 +309,13 @@ private slots: QVERIFY(actionsLinksContextMenu.isEmpty() || actionsLinksContextMenu.size() < actionsLinks.size()); // context menu must not contain the primary action - QVERIFY(std::find_if(std::begin(actionsLinksContextMenu), std::end(actionsLinksContextMenu), - [](const QVariant &entry) { return entry.value()._primary; }) - == std::end(actionsLinksContextMenu)); + QVERIFY(std::all_of(std::begin(actionsLinksContextMenu), std::end(actionsLinksContextMenu), + [&actionsLinks](const QVariant &entry) { + auto ok = false; + const auto actionIndex = entry.toMap().value(QStringLiteral("actionIndex")).toInt(&ok); + return ok && actionIndex >= 0 && actionIndex < actionsLinks.size() + && !actionsLinks[actionIndex].value()._primary; + })); const auto objectType = index.data(OCC::ActivityListModel::ObjectTypeRole).toString(); @@ -350,7 +354,7 @@ private slots: // both action links and buttons must contain a "REPLY" verb element as secondary action QVERIFY(actionsLinks[replyActionPos].value()._verb == QStringLiteral("REPLY")); - //QVERIFY(actionButtonsLinks[replyActionPos].value()._verb == QStringLiteral("REPLY")); + QCOMPARE(actionButtonsLinks[replyActionPos].toMap().value(QStringLiteral("verb")).toString(), QStringLiteral("REPLY")); // the first action button for chat must have image set //QVERIFY(!actionButtonsLinks[replyActionPos].value()._imageSource.isEmpty()); @@ -361,7 +365,7 @@ private slots: || (objectType != QStringLiteral("room") && objectType != QStringLiteral("call")))) { // button's label for "chat" must be renamed to "Reply" - QVERIFY(actionButtonsLinks[0].value()._label == QObject::tr("Reply")); + QCOMPARE(actionButtonsLinks[0].toMap().value(QStringLiteral("label")).toString(), QObject::tr("Reply")); if (static_cast(actionsLinks.size()) > OCC::ActivityListModel::maxActionButtons()) { QCOMPARE(actionButtonsLinks.size(), OCC::ActivityListModel::maxActionButtons()); @@ -369,7 +373,7 @@ private slots: QCOMPARE(actionButtonsLinks.size() + actionsLinksContextMenu.size(), actionsLinks.size()); } } else if ((objectType == QStringLiteral("call"))) { - QVERIFY(actionButtonsLinks[0].value()._label == QStringLiteral("Call back")); + QCOMPARE(actionButtonsLinks[0].toMap().value(QStringLiteral("label")).toString(), QStringLiteral("Call back")); } } } diff --git a/test/testsetuserstatusdialog.cpp b/test/testsetuserstatusdialog.cpp index faf7e4fecd486..407095ba00282 100644 --- a/test/testsetuserstatusdialog.cpp +++ b/test/testsetuserstatusdialog.cpp @@ -102,6 +102,11 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector emit UserStatusConnector::userStatusSet(); } + void emitError(Error error) + { + emit UserStatusConnector::error(error); + } + void setErrorCouldNotFetchPredefinedUserStatuses(bool value) { _couldNotFetchPredefinedUserStatuses = value; @@ -249,6 +254,7 @@ private slots: OCC::UserStatusSelectorModel model(fakeUserStatusJob, std::move(fakeDateTimeProvider)); // Was user status set correctly? + QVERIFY(model.userStatusLoaded()); QCOMPARE(model.userStatusMessage(), userStatusMessage); QCOMPARE(model.userStatusEmoji(), userStatusIcon); QCOMPARE(model.onlineStatus(), userStatusState); @@ -274,6 +280,7 @@ private slots: { OCC::UserStatusSelectorModel model(nullptr, nullptr); + QVERIFY(!model.userStatusLoaded()); QCOMPARE(model.userStatusMessage(), ""); QCOMPARE(model.userStatusEmoji(), ""); QCOMPARE(model.clearAtDisplayString(), QStringLiteral("Don't clear")); @@ -286,6 +293,7 @@ private slots: OCC::UserStatus::OnlineStatus::Offline, false, {} }); OCC::UserStatusSelectorModel model(fakeUserStatusJob); + QVERIFY(model.userStatusLoaded()); QCOMPARE(model.onlineStatus(), OCC::UserStatus::OnlineStatus::Offline); QCOMPARE(model.userStatusMessage(), ""); QCOMPARE(model.userStatusEmoji(), ""); @@ -812,10 +820,36 @@ private slots: fakeUserStatusJob->setErrorCouldNotFetchUserStatus(true); OCC::UserStatusSelectorModel model(fakeUserStatusJob); + QVERIFY(!model.userStatusLoaded()); QCOMPARE(model.errorMessage(), QStringLiteral("Could not fetch status. Make sure you are connected to the server.")); } + void testError_couldNotFetchUserStatus_clearStatusAndDisableWrites() + { + const OCC::UserStatus userStatus("fake-id", "Old message", "old-icon", + OCC::UserStatus::OnlineStatus::DoNotDisturb, false, {}); + auto fakeUserStatusJob = std::make_shared(); + fakeUserStatusJob->setFakeUserStatus(userStatus); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + + QVERIFY(model.userStatusLoaded()); + QCOMPARE(model.userStatusMessage(), userStatus.message()); + QCOMPARE(model.userStatusEmoji(), userStatus.icon()); + + fakeUserStatusJob->emitError(OCC::UserStatusConnector::Error::CouldNotFetchUserStatus); + + QVERIFY(!model.userStatusLoaded()); + QCOMPARE(model.userStatusMessage(), ""); + QCOMPARE(model.userStatusEmoji(), ""); + QCOMPARE(model.clearAtDisplayString(), QStringLiteral("Don't clear")); + + model.setUserStatusMessage(QStringLiteral("Should not be saved")); + model.setUserStatus(); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 0); + } + void testError_userStatusNotSupported_emitError() { auto fakeUserStatusJob = std::make_shared(); From bff64e8ba0a03a4ee47ffcfd68f1d4b5f1ed3239 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 14:32:21 +0200 Subject: [PATCH 04/19] fix: moves code related to Activity class inside the class itself will make maintenance easier Signed-off-by: Matthieu Gallien --- src/gui/tray/activitydata.cpp | 336 ++++++++++++++++++++++++++ src/gui/tray/activitydata.h | 58 ++++- src/gui/tray/activitylistmodel.cpp | 371 +---------------------------- 3 files changed, 404 insertions(+), 361 deletions(-) diff --git a/src/gui/tray/activitydata.cpp b/src/gui/tray/activitydata.cpp index aeaad62a3a08b..2405610661b71 100644 --- a/src/gui/tray/activitydata.cpp +++ b/src/gui/tray/activitydata.cpp @@ -248,4 +248,340 @@ QString Activity::relativeServerFileTypeIconPath(const QMimeType &mimeType) return defaultIcon; } +bool Activity::isSyncIssue() const +{ + if (_type == Activity::SyncResultType) { + return true; + } + + if (_type != Activity::SyncFileItemType) { + return false; + } + + return _syncFileItemStatus != SyncFileItem::NoStatus + && _syncFileItemStatus != SyncFileItem::Success; +} + +bool Activity::isRecentActivityPreviewCandidate() const +{ + switch (_type) { + case Activity::ActivityType: + return true; + case Activity::SyncFileItemType: + return !isSyncIssue(); + case Activity::NotificationType: + case Activity::OpenSettingsNotificationType: + case Activity::SyncResultType: + case Activity::DummyFetchingActivityType: + case Activity::DummyMoreActivitiesAvailableType: + return false; + } + return false; +} + +bool Activity::isNotificationPreviewCandidate() const +{ + return _type == Activity::NotificationType + || _type == Activity::OpenSettingsNotificationType; +} + +bool Activity::isDismissableActivity() const +{ + return !_links.isEmpty() && + _syncFileItemStatus != SyncFileItem::FileNameClash && + _syncFileItemStatus != SyncFileItem::Conflict && + _syncFileItemStatus != SyncFileItem::FileNameInvalid && + _syncFileItemStatus != SyncFileItem::FileNameInvalidOnServer; +} + +QVariantList Activity::notificationPreviewActions() const +{ + auto actions = QVariantList{}; + + if (isDismissableActivity()) { + actions.push_back(notificationPreviewAction( + QCoreApplication::translate("ActivityItemContent", "Dismiss"), + QStringLiteral("dismiss"), + -1, + QByteArrayLiteral("DELETE"))); + } + + const auto &actionsList = activityActionMetadata(); + for (const auto &action : actionsList) { + 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 Activity::activitySubjectText() const +{ + if (!_subjectDisplay.isEmpty()) { + return _subjectDisplay; + } + return _subject; +} + +QString Activity::subjectWithoutRichParameter(const QString ¶meterKey) const +{ + if (_subjectRich.isEmpty() || parameterKey.isEmpty()) { + return activitySubjectText(); + } + + auto displayString = _subjectRich; + for (auto it = _subjectRichParameters.cbegin(); it != _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); +} + +QString Activity::compactNotificationTitle() const +{ + if (!_subjectDisplay.isEmpty()) { + return _subjectDisplay; + } + if (!_subject.isEmpty()) { + return _subject; + } + return _message; +} + +QString Activity::recentActivitySystemIconName() const +{ + switch (_type) { + case Activity::NotificationType: + case Activity::OpenSettingsNotificationType: + return QStringLiteral("bell"); + case Activity::SyncResultType: + return QStringLiteral("exclamationmark.triangle"); + case Activity::SyncFileItemType: + if (_syncFileItemStatus == SyncFileItem::NormalError + || _syncFileItemStatus == SyncFileItem::FatalError + || _syncFileItemStatus == SyncFileItem::DetailError + || _syncFileItemStatus == SyncFileItem::BlacklistedError) { + return QStringLiteral("exclamationmark.triangle"); + } + if (_syncFileItemStatus == SyncFileItem::SoftError + || _syncFileItemStatus == SyncFileItem::Conflict + || _syncFileItemStatus == SyncFileItem::Restoration + || _syncFileItemStatus == SyncFileItem::FileLocked + || _syncFileItemStatus == SyncFileItem::FileNameInvalid + || _syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer + || _syncFileItemStatus == SyncFileItem::FileNameClash) { + return QStringLiteral("exclamationmark.triangle"); + } + if (_fileAction == "file_deleted"_L1) { + return QStringLiteral("trash"); + } + if (_fileAction == "file_renamed"_L1) { + return QStringLiteral("pencil"); + } + return QStringLiteral("doc"); + case Activity::ActivityType: + if (_objectType == "chat"_L1 || _objectType == "room"_L1 || _objectType == "call"_L1) { + return QStringLiteral("message"); + } + if (_objectType == "calendar"_L1) { + return QStringLiteral("calendar"); + } + if (_fileAction == "file_deleted"_L1) { + return QStringLiteral("trash"); + } + return QStringLiteral("doc"); + case Activity::DummyFetchingActivityType: + case Activity::DummyMoreActivitiesAvailableType: + return QStringLiteral("clock"); + } + return QStringLiteral("doc"); +} + +Activity::RecentActivityPreviewText Activity::recentActivityPreviewText() const +{ + auto title = QString{}; + auto subtitle = QString{}; + auto richParameter = Activity::RichSubjectPreviewParameter{}; + + for (const auto &preview : _previews) { + title = Activity::compactPathLabel(preview._filename); + if (!title.isEmpty()) { + break; + } + } + + if (title.isEmpty()) { + richParameter = firstRichSubjectPreviewObjectParameter(); + title = richParameter.label; + } + + if (title.isEmpty()) { + for (const auto &candidate : {_objectName, _file, _renamedFile}) { + title = compactPathLabel(candidate); + if (!title.isEmpty()) { + break; + } + } + } + + if (_type == Activity::SyncFileItemType) { + subtitle = !_message.isEmpty() ? _message : activitySubjectText(); + } else { + subtitle = subjectWithoutRichParameter(richParameter.key); + } + + if (title.isEmpty()) { + title = activitySubjectText(); + subtitle = _message; + } + + if (subtitle == title) { + subtitle = _message; + } + + return {title, subtitle}; +} + +Activity::RichSubjectPreviewParameter Activity::firstRichSubjectPreviewObjectParameter() const +{ + const auto parameters = richSubjectPreviewParameters(_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 {}; +} + +QVariantMap Activity::notificationPreviewAction(const QString &label, const QString &actionType, const int actionIndex, 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; +} + +QString Activity::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 Activity::normalizedPreviewText(QString text) +{ + static const auto regexp = QRegularExpression{u"\\s+"_s}; + text.replace(regexp, QStringLiteral(" ")); + return text.trimmed(); +} + +QString Activity::richSubjectParameterLabel(const RichSubjectParameter ¶meter) +{ + return compactPathLabel(!parameter.path.isEmpty() ? parameter.path : parameter.name); +} + +Activity::RichSubjectPreviewParameter Activity::firstRichSubjectPreviewParameterByType(const QVector ¶meters, + const QStringList &types) +{ + for (const auto &type : types) { + for (const auto ¶meter : parameters) { + if (parameter.type == type) { + return parameter; + } + } + } + return {}; +} + +QVector Activity::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; +} + +QVector Activity::activityActionMetadata(const int maximumActionIndex) const +{ + auto actions = QVector{}; + actions.reserve(_links.size()); + + for (auto i = 0; i < _links.size(); ++i) { + if (maximumActionIndex >= 0 && i > maximumActionIndex) { + break; + } + + const auto &activityLink = _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") + && _objectType != QStringLiteral("remote_share"); + const auto isTalkWebAction = activityLink._verb == QByteArrayLiteral("WEB") + && _objectType == QStringLiteral("chat"); + if (isUseCustomDeleteAction || isTalkWebAction || activityLink._label.isEmpty()) { + continue; + } + + actions.push_back({i, activityLink}); + } + + return actions; +} + } diff --git a/src/gui/tray/activitydata.h b/src/gui/tray/activitydata.h index 57ecaa8a5a097..85be7e6515e41 100644 --- a/src/gui/tray/activitydata.h +++ b/src/gui/tray/activitydata.h @@ -11,7 +11,6 @@ #include "syncresult.h" #include "account.h" -#include #include #include #include @@ -142,6 +141,22 @@ class Activity Q_PROPERTY(QVariantMap subjectRichParameters MEMBER _subjectRichParameters) public: + struct ActivityActionMetadata { + int actionIndex = -1; + ActivityLink link; + }; + + struct RichSubjectPreviewParameter { + QString key; + QString type; + QString label; + }; + + struct RecentActivityPreviewText { + QString title; + QString subtitle; + }; + using Identifier = QPair; // Note that these are in the order we want to present them in the model! @@ -203,6 +218,47 @@ class Activity bool _shouldNotify = true; [[nodiscard]] Identifier ident() const; + + [[nodiscard]] bool isSyncIssue() const; + + [[nodiscard]] bool isRecentActivityPreviewCandidate() const; + + [[nodiscard]] bool isNotificationPreviewCandidate() const; + + [[nodiscard]] bool isDismissableActivity() const; + + [[nodiscard]] QVariantList notificationPreviewActions() const; + + [[nodiscard]] QVector activityActionMetadata(const int maximumActionIndex = -1) const; + + [[nodiscard]] QString activitySubjectText() const; + + [[nodiscard]] RichSubjectPreviewParameter firstRichSubjectPreviewObjectParameter() const; + + [[nodiscard]] QString subjectWithoutRichParameter(const QString ¶meterKey) const; + + [[nodiscard]] RecentActivityPreviewText recentActivityPreviewText() const; + + [[nodiscard]] QString compactNotificationTitle() const; + + [[nodiscard]] QString recentActivitySystemIconName() const; + +private: + static QVariantMap notificationPreviewAction(const QString &label, + const QString &actionType, + const int actionIndex = -1, + const QByteArray &verb = {}); + + static QString richSubjectParameterLabel(const Activity::RichSubjectParameter ¶meter); + + static QVector richSubjectPreviewParameters(const QVariantMap ¶meters); + + static RichSubjectPreviewParameter firstRichSubjectPreviewParameterByType(const QVector ¶meters, + const QStringList &types); + + static QString compactPathLabel(const QString &path); + + static QString normalizedPreviewText(QString text); }; bool operator==(const Activity &rhs, const Activity &lhs); diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 6efe7ad646a41..04d0663659bb2 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -19,7 +19,6 @@ #include "systray.h" #include "common/utility.h" -#include #include #include #include @@ -36,361 +35,12 @@ 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) @@ -785,7 +435,7 @@ QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) for (auto row = 0; row < _finalList.size(); ++row) { const auto activity = _finalList.at(row); - if (isRecentActivityPreviewCandidate(activity)) { + if (activity.isRecentActivityPreviewCandidate()) { candidates.push_back({row, activity}); } } @@ -796,7 +446,7 @@ QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) auto recentActivities = QVariantList{}; for (const auto &candidate : std::as_const(candidates)) { - const auto previewText = recentActivityPreviewText(candidate.activity); + const auto previewText = candidate.activity.recentActivityPreviewText(); if (previewText.title.isEmpty()) { continue; } @@ -806,7 +456,7 @@ QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) {QStringLiteral("title"), previewText.title}, {QStringLiteral("subtitle"), previewText.subtitle}, {QStringLiteral("icon"), data(modelIndex, IconRole).toString()}, - {QStringLiteral("systemIconName"), recentActivitySystemIconName(candidate.activity)}, + {QStringLiteral("systemIconName"), candidate.activity.recentActivitySystemIconName()}, {QStringLiteral("dateTime"), data(modelIndex, PointInTimeRole).toString()}, {QStringLiteral("activityIndex"), candidate.row}, }); @@ -826,7 +476,7 @@ QVariantList ActivityListModel::buildNotificationPreviewData() const for (auto row = 0; row < _finalList.size(); ++row) { const auto activity = _finalList.at(row); - if (isNotificationPreviewCandidate(activity)) { + if (activity.isNotificationPreviewCandidate()) { candidates.push_back({row, activity}); } } @@ -837,21 +487,21 @@ QVariantList ActivityListModel::buildNotificationPreviewData() const auto notifications = QVariantList{}; for (const auto &candidate : std::as_const(candidates)) { - const auto title = compactNotificationTitle(candidate.activity); + const auto title = candidate.activity.compactNotificationTitle(); if (title.isEmpty()) { continue; } const auto modelIndex = index(candidate.row); - const auto actions = notificationPreviewActions(candidate.activity); + const auto actions = candidate.activity.notificationPreviewActions(); notifications.push_back(QVariantMap{ {QStringLiteral("title"), title}, {QStringLiteral("icon"), data(modelIndex, IconRole).toString()}, - {QStringLiteral("systemIconName"), recentActivitySystemIconName(candidate.activity)}, + {QStringLiteral("systemIconName"), candidate.activity.recentActivitySystemIconName()}, {QStringLiteral("dateTime"), data(modelIndex, PointInTimeRole).toString()}, {QStringLiteral("activityIndex"), candidate.row}, {QStringLiteral("opensSettings"), candidate.activity._type == Activity::OpenSettingsNotificationType}, - {QStringLiteral("canDismiss"), isDismissableActivity(candidate.activity)}, + {QStringLiteral("canDismiss"), candidate.activity.isDismissableActivity()}, {QStringLiteral("actions"), actions}, }); } @@ -1422,7 +1072,8 @@ QVariantList ActivityListModel::convertLinksToActionButtons(const Activity &acti { QVariantList customList; - for (const auto &action : activityActionMetadata(activity, maxActionButtons())) { + const auto &actionsList = activity.activityActionMetadata(maxActionButtons()); + for (const auto &action : actionsList) { customList << ActivityListModel::convertLinkToActionButton(action.link, action.actionIndex); } From bbbf364c282310621309fd2399ba161750744daf Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 14:46:33 +0200 Subject: [PATCH 05/19] fix: small clean up in activity list model class Signed-off-by: Matthieu Gallien --- src/gui/tray/activitylistmodel.cpp | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 04d0663659bb2..26e5a0e5d1bdd 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -34,6 +34,8 @@ namespace OCC { Q_LOGGING_CATEGORY(lcActivity, "nextcloud.gui.activity", QtInfoMsg) +constexpr auto defaultRecentActivityPreviewCount = 5; + namespace { struct RecentActivityPreviewItem { @@ -412,11 +414,13 @@ int ActivityListModel::rowCount(const QModelIndex &parent) const QVariantList ActivityListModel::recentActivityPreviewData(const int limit) const { - if (limit == 5) { - return _recentActivityPreviewData; + auto result = _recentActivityPreviewData; + + if (limit != defaultRecentActivityPreviewCount) { + result = buildRecentActivityPreviewData(limit); } - return buildRecentActivityPreviewData(limit); + return result; } QVariantList ActivityListModel::notificationPreviewData() const @@ -426,8 +430,10 @@ QVariantList ActivityListModel::notificationPreviewData() const QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) const { + auto recentActivities = QVariantList{}; + if (limit <= 0) { - return {}; + return recentActivities; } auto candidates = QVector{}; @@ -444,7 +450,6 @@ QVariantList ActivityListModel::buildRecentActivityPreviewData(const int limit) return left.activity._dateTime > right.activity._dateTime; }); - auto recentActivities = QVariantList{}; for (const auto &candidate : std::as_const(candidates)) { const auto previewText = candidate.activity.recentActivityPreviewText(); if (previewText.title.isEmpty()) { @@ -1094,13 +1099,13 @@ QVariant ActivityListModel::convertLinkToActionButton(const OCC::ActivityLink &a } 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())}, + {u"actionIndex"_s, actionIndex}, + {u"imageSource"_s, activityLinkCopy._imageSource}, + {u"imageSourceHovered"_s, activityLinkCopy._imageSourceHovered}, + {u"label"_s, activityLinkCopy._label}, + {u"link"_s, activityLinkCopy._link}, + {u"primary"_s, activityLinkCopy._primary}, + {u"verb"_s, QString::fromUtf8(activityLinkCopy._verb.constData(), activityLinkCopy._verb.size())}, }; } From 4153d38deb7a6c5d2f18e8535578f94a943f2666 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 14:50:12 +0200 Subject: [PATCH 06/19] fix: do not rename existing variables when not needed Signed-off-by: Matthieu Gallien --- src/gui/tray/syncstatussummary.cpp | 6 +++--- src/gui/tray/syncstatussummary.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gui/tray/syncstatussummary.cpp b/src/gui/tray/syncstatussummary.cpp index 535788f6335eb..1f730443e9f93 100644 --- a/src/gui/tray/syncstatussummary.cpp +++ b/src/gui/tray/syncstatussummary.cpp @@ -91,13 +91,13 @@ void SyncStatusSummary::load() loadForUser(UserModel::instance()->currentUser()); } -void SyncStatusSummary::loadForUser(User *user) +void SyncStatusSummary::loadForUser(User *currentUser) { - if (!user) { + if (!currentUser) { return; } - setAccountState(user->accountState()); + setAccountState(currentUser->accountState()); clearFolderErrors(); connectToFoldersProgress(FolderMan::instance()->map()); initSyncState(); diff --git a/src/gui/tray/syncstatussummary.h b/src/gui/tray/syncstatussummary.h index b2de47ae1f170..8e23875bcb1ac 100644 --- a/src/gui/tray/syncstatussummary.h +++ b/src/gui/tray/syncstatussummary.h @@ -53,7 +53,7 @@ class SyncStatusSummary : public QObject public slots: void load(); - void loadForUser(OCC::User *user); + void loadForUser(OCC::User *currentUser); private: void connectToFoldersProgress(const Folder::Map &map); From a93339d8a5594957690667c4b857f3a20ba5967e Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 7 Jul 2026 16:32:52 +0200 Subject: [PATCH 07/19] style(macOS): change the text for some tray menu options. Co-authored-by: Iva Horn Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 4 ++-- src/gui/trayaccountpopup_qt.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 0ad7f21d7dbf1..ee27ca0d136e4 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -1531,7 +1531,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc }]); [_stack addArrangedSubview:accountActionsSeparator()]; } - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Local folder").toNSString() + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Reveal in Finder").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ @@ -1549,7 +1549,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]); } - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "More apps").toNSString() + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "Apps").toNSString() icon:nil width:kAccountActionsPopupWidth enabled:appsEnabled diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 5c1cd8e0596c8..fb43296792e95 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -744,7 +744,7 @@ void populateAccountMenu(QMenu *menu, const int userId, const bool fetchActivity const auto appsMenu = addSubMenu(menu, templateBlackThemeIcon(QStringLiteral("more-apps.svg"), menuIconSize, menuIconPalette), - QCoreApplication::translate("TrayWindowHeader", "More apps")); + QCoreApplication::translate("TrayWindowHeader", "Apps")); setFixedMenuWidth(appsMenu); appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); QObject::connect(appsMenu, &QMenu::aboutToShow, appsMenu, [appsMenu, userId] { From 16cadd2f3ccf359863361ce047a847a255915d08 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 14:54:07 +0200 Subject: [PATCH 08/19] fix: better code style almost always use enum class to get more strict checks by compiler use missing Qt macro definitions for better bindings Signed-off-by: Matthieu Gallien --- src/gui/tray/trayaccountappsmodel.cpp | 33 ++++++++++++++++----------- src/gui/tray/trayaccountappsmodel.h | 16 ++++++++----- src/gui/trayaccountpopup_qt.cpp | 6 ++--- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp index af44eb09d3c77..37f40b96145be 100644 --- a/src/gui/tray/trayaccountappsmodel.cpp +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -89,28 +89,35 @@ int TrayAccountAppsModel::count() const QVariant TrayAccountAppsModel::data(const QModelIndex &index, const int role) const { + auto result = QVariant{}; + if (index.row() < 0 || index.row() >= _apps.size()) { - return {}; + return result; } - 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 {}; + if (role >= static_cast(Roles::NameRole) && role < static_cast(Roles::IconUrlRole)) { + switch (static_cast(role)) { + case Roles::NameRole: + result = _apps[index.row()]->name(); + break; + case Roles::UrlRole: + result = _apps[index.row()]->url(); + break; + case Roles::IconUrlRole: + result = _apps[index.row()]->iconUrl().toString(); + break; + } } + + return result; } QHash TrayAccountAppsModel::roleNames() const { return { - { NameRole, "appName" }, - { UrlRole, "appUrl" }, - { IconUrlRole, "appIconUrl" }, + { static_cast(Roles::NameRole), "appName" }, + { static_cast(Roles::UrlRole), "appUrl" }, + { static_cast(Roles::IconUrlRole), "appIconUrl" }, }; } diff --git a/src/gui/tray/trayaccountappsmodel.h b/src/gui/tray/trayaccountappsmodel.h index 79337528c3e5b..2d37260217210 100644 --- a/src/gui/tray/trayaccountappsmodel.h +++ b/src/gui/tray/trayaccountappsmodel.h @@ -22,22 +22,26 @@ class TrayAccountAppsModel : public QAbstractListModel static TrayAccountAppsModel *instance(); ~TrayAccountAppsModel() override = default; - enum Roles { + enum class Roles { NameRole = Qt::UserRole + 1, UrlRole, - IconUrlRole + IconUrlRole, }; + Q_ENUM(Roles) + [[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: +Q_SIGNALS: void countChanged(); +public Q_SLOTS: + void setUserId(int userId); + + void openAppUrl(const QUrl &url); + protected: [[nodiscard]] QHash roleNames() const override; diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index fb43296792e95..068761de7b17f 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -554,9 +554,9 @@ bool populateAppsMenu(QMenu *menu, const int userId) 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(); - const auto appIconUrl = appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl(); + const auto appName = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::NameRole)).toString(); + const auto appUrl = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::UrlRole)).toUrl(); + const auto appIconUrl = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::IconUrlRole)).toUrl(); auto appIcon = iconFromUrl(appIconUrl); if (appIcon.isNull()) { appIcon = templateBlackThemeIcon(QStringLiteral("more-apps.svg"), appIconSize, appIconPalette); From 439dce4e60bbf4fc0f1cdbf786d61f7e414815bc Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 15:31:12 +0200 Subject: [PATCH 09/19] fix: fix issues reported by static code checks Signed-off-by: Matthieu Gallien --- src/gui/trayaccountpopup_qt.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 068761de7b17f..34234d362fc7c 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -42,6 +42,8 @@ #include +using namespace Qt::StringLiterals; + namespace OCC { // Keep behavior and menu taxonomy aligned with src/gui/macOS/trayaccountpopup_mac.mm. @@ -55,8 +57,8 @@ constexpr auto menuTextReservedWidth = 40; constexpr auto submenuIndicatorReservedWidth = 18; constexpr auto minimumElidedTextWidth = 32; -QPointer s_trayPopup; -QHash s_remoteAppIconCache; +static QPointer s_trayPopup; +static QHash s_remoteAppIconCache; void setFixedMenuWidth(QMenu *menu) { @@ -375,7 +377,7 @@ QString elidedTimedMenuText(const QMenu *menu, const QString &text, const QStrin return elidedMenuText(menu, timedMenuText(text, dateTime), hasSubmenu); } - const auto timeSuffix = QStringLiteral(" - ") + dateTime; + const auto timeSuffix = QString{u" - "_s + dateTime}; const auto availableTextWidth = menuTextWidth(hasSubmenu); const auto messageTextWidth = std::max( availableTextWidth - menu->fontMetrics().horizontalAdvance(timeSuffix), @@ -523,7 +525,7 @@ void openSettingsAfterTrayPopupCloses() { closeTrayPopup(); QTimer::singleShot(0, Systray::instance(), [] { - Systray::instance()->openSettings(); + Q_EMIT Systray::instance()->openSettings(); }); } @@ -809,7 +811,7 @@ void populateTrayMenu(QMenu *menu, Systray *systray) Systray::tr("Add account")); QObject::connect(addAccountAction, &QAction::triggered, addAccountAction, [] { closeTrayPopup(); - Systray::instance()->openAccountWizard(); + Q_EMIT Systray::instance()->openAccountWizard(); }); } @@ -825,7 +827,7 @@ void populateTrayMenu(QMenu *menu, Systray *systray) Systray::tr("Quit")); QObject::connect(quitAction, &QAction::triggered, quitAction, [] { closeTrayPopup(); - Systray::instance()->shutdown(); + Q_EMIT Systray::instance()->shutdown(); }); } From 3acbe3bfb7c0989e4f7041e82bff637c85ed71d1 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 15:55:38 +0200 Subject: [PATCH 10/19] fix: fix warnings reported by static code checks Signed-off-by: Matthieu Gallien --- src/gui/tray/usermodel.cpp | 67 +++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 04ad656affb29..44ade499a77d9 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -284,32 +284,40 @@ bool isSyncStatusError(const OCC::SyncResult::Status status) SyncIssueKind syncIssueKindForSyncResult(const OCC::SyncResult::Status status) { + auto result = SyncIssueKind::None; + switch (status) { case OCC::SyncResult::Error: case OCC::SyncResult::SetupError: - return SyncIssueKind::Error; + result = SyncIssueKind::Error; + break; case OCC::SyncResult::Problem: case OCC::SyncResult::Undefined: - return SyncIssueKind::Warning; + result = SyncIssueKind::Warning; + break; 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; + result = SyncIssueKind::None; + break; } - return SyncIssueKind::None; + return result; } SyncIssueKind syncIssueKindForSyncFileItem(const OCC::SyncFileItem::Status status) { + auto result = SyncIssueKind::None; + switch (status) { case OCC::SyncFileItem::NormalError: case OCC::SyncFileItem::FatalError: case OCC::SyncFileItem::DetailError: case OCC::SyncFileItem::BlacklistedError: - return SyncIssueKind::Error; + result = SyncIssueKind::Error; + break; case OCC::SyncFileItem::SoftError: case OCC::SyncFileItem::Conflict: case OCC::SyncFileItem::FileIgnored: @@ -318,43 +326,56 @@ SyncIssueKind syncIssueKindForSyncFileItem(const OCC::SyncFileItem::Status statu case OCC::SyncFileItem::FileNameInvalid: case OCC::SyncFileItem::FileNameInvalidOnServer: case OCC::SyncFileItem::FileNameClash: - return SyncIssueKind::Warning; + result = SyncIssueKind::Warning; + break; case OCC::SyncFileItem::NoStatus: case OCC::SyncFileItem::Success: - return SyncIssueKind::None; + result = SyncIssueKind::None; + break; } - return SyncIssueKind::None; + return result; } SyncIssueKind strongestSyncIssueKind(const SyncIssueKind left, const SyncIssueKind right) { + auto result = SyncIssueKind::None; + if (left == SyncIssueKind::Error || right == SyncIssueKind::Error) { - return SyncIssueKind::Error; + result = SyncIssueKind::Error; + return result; } if (left == SyncIssueKind::Warning || right == SyncIssueKind::Warning) { - return SyncIssueKind::Warning; + result = SyncIssueKind::Warning; + return result; } - return SyncIssueKind::None; + + return result; } SyncIssueKind syncIssueKindForActivity(const OCC::Activity &activity) { + auto result = SyncIssueKind::None; + if (activity._type == OCC::Activity::SyncResultType) { - return syncIssueKindForSyncResult(activity._syncResultStatus); + result = syncIssueKindForSyncResult(activity._syncResultStatus); + return result; } if (activity._type == OCC::Activity::SyncFileItemType) { - return syncIssueKindForSyncFileItem(activity._syncFileItemStatus); + result = syncIssueKindForSyncFileItem(activity._syncFileItemStatus); + return result; } - return SyncIssueKind::None; + + return result; } SyncIssueKind syncIssueKindForActivities(const OCC::ActivityListModel *activityModel) { + auto result = SyncIssueKind::None; + if (!activityModel) { - return SyncIssueKind::None; + return result; } - auto result = SyncIssueKind::None; for (const auto &activity : activityModel->activityList()) { result = strongestSyncIssueKind(result, syncIssueKindForActivity(activity)); if (result == SyncIssueKind::Error) { @@ -370,7 +391,8 @@ bool accountHasSyncErrors(const OCC::AccountStatePtr &accountState) return false; } - for (const auto folder : OCC::FolderMan::instance()->map().values()) { + const auto &allFolders = OCC::FolderMan::instance()->map().values(); + for (const auto folder : allFolders) { if (folder->accountState() != accountState.data()) { continue; } @@ -397,7 +419,8 @@ SyncIssueKind syncIssueKindForAccount(const OCC::AccountStatePtr &accountState) } auto result = SyncIssueKind::None; - for (const auto folder : OCC::FolderMan::instance()->map().values()) { + const auto &allFolders = OCC::FolderMan::instance()->map().values(); + for (const auto folder : allFolders) { if (folder->accountState() != accountState.data()) { continue; } @@ -516,7 +539,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) this, &User::slotCheckExpiredActivities); connect(_account.data(), &AccountState::stateChanged, - [=, this]() { if (isConnected()) {slotRefreshImmediately();} }); + this, [=, 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, @@ -861,7 +884,7 @@ void User::slotCheckExpiredActivities() { const auto errorsList = _activityModel->errorsList(); for (const auto &activity : errorsList) { - if (activity._expireAtMsecs > 0 && QDateTime::currentDateTime().toMSecsSinceEpoch() >= activity._expireAtMsecs) { + if (activity._expireAtMsecs > 0 && QDateTime::currentMSecsSinceEpoch() >= activity._expireAtMsecs) { _activityModel->removeActivityFromActivityList(activity); } } @@ -1311,7 +1334,7 @@ void User::slotProgressInfo(const QString &folder, const ProgressInfo &progress) continue; } - if (const auto filePath = f->path() + activity._file; !FileSystem::fileExists(filePath)) { + if (const auto filePath = QString{f->path() + activity._file}; !FileSystem::fileExists(filePath)) { _activityModel->removeActivityFromActivityList(activity); continue; } @@ -2577,7 +2600,7 @@ QImage UserModel::syncStatusIconForRow(const int row) const return {}; } const auto url = _users[row]->syncStatusIcon(); - const auto resourcePath = QStringLiteral(":") + url.path(); + const auto resourcePath = QString{u":"_s + url.path()}; return QIcon(resourcePath).pixmap(18, 18).toImage(); } From 1ee21251a5122f2aa85e77f34499c1e0d1b79a9a Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 17:08:00 +0200 Subject: [PATCH 11/19] fix: we already require Qt 6.10.0 minimum Signed-off-by: Matthieu Gallien --- src/gui/systray.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index bb9befdd4cc80..a894bbbe8cc41 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -49,7 +49,7 @@ namespace OCC { Q_LOGGING_CATEGORY(lcSystray, "nextcloud.gui.systray") namespace { -#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) +#if defined(Q_OS_MACOS) constexpr auto macOSWindowDragHandleHeight = 28; class QuickWindowDragHandle : public QObject @@ -297,7 +297,7 @@ void Systray::showActivitiesWindow(int userIndex) window->installEventFilter(fgbg); #endif -#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) +#if defined(Q_OS_MACOS) configureMacOSExpandedQuickWindow(window); #endif @@ -373,7 +373,7 @@ void Systray::showAssistantWindow(int userIndex) window->installEventFilter(fgbg); #endif -#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) +#if defined(Q_OS_MACOS) configureMacOSExpandedQuickWindow(window); #endif @@ -447,7 +447,7 @@ void Systray::showUserStatusWindow(int userIndex) _userStatusWindow->installEventFilter(fgbg); #endif -#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) +#if defined(Q_OS_MACOS) configureMacOSExpandedQuickWindow(_userStatusWindow.data()); #endif From c3a7918131ab50241e31cd153f3489c077271230 Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 17:41:48 +0200 Subject: [PATCH 12/19] fix: fix code style such that a slot has no return value return values of Qt slots are ignored it was never checked by callers Signed-off-by: Matthieu Gallien --- src/gui/systray.cpp | 4 ++-- src/gui/systray.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index a894bbbe8cc41..6c821127be160 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -129,9 +129,9 @@ void Systray::setTrayEngine(QQmlApplicationEngine *trayEngine) _trayEngine->addImageProvider(QLatin1String("tray-image-provider"), new TrayImageProvider); } -bool Systray::openUrlInBrowser(const QUrl &url) const +void Systray::openUrlInBrowser(const QUrl &url) const { - return Utility::openBrowser(url); + Utility::openBrowser(url); } Systray::Systray() diff --git a/src/gui/systray.h b/src/gui/systray.h index 196f892c630d4..bf3e4e93ebaf9 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -121,7 +121,7 @@ class Systray : public QSystemTrayIcon void hideSettingsDialog(); public slots: - [[nodiscard]] bool openUrlInBrowser(const QUrl &url) const; + void openUrlInBrowser(const QUrl &url) const; void setTrayEngine(QQmlApplicationEngine *trayEngine); void create(); From 35c9c0b973fa062bda1d1795366c3fbae5c132ff Mon Sep 17 00:00:00 2001 From: Matthieu Gallien Date: Tue, 7 Jul 2026 17:49:32 +0200 Subject: [PATCH 13/19] fix: fix invalid behavior in rowCount method of a Qt based model rowCount in a flat mdoel shoudl return 0 for every parent that is valid otherwise, views may think that the menu has child items for every existing items also do not add not needed count method (reuse existing rowCount) Signed-off-by: Matthieu Gallien --- src/gui/tray/trayaccountappsmodel.cpp | 8 +++----- src/gui/tray/trayaccountappsmodel.h | 3 +-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp index 37f40b96145be..a97a27890b172 100644 --- a/src/gui/tray/trayaccountappsmodel.cpp +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -78,12 +78,10 @@ void TrayAccountAppsModel::openAppUrl(const QUrl &url) int TrayAccountAppsModel::rowCount(const QModelIndex &parent) const { - Q_UNUSED(parent); - return count(); -} + if (parent.isValid()) { + return 0; + } -int TrayAccountAppsModel::count() const -{ return _apps.size(); } diff --git a/src/gui/tray/trayaccountappsmodel.h b/src/gui/tray/trayaccountappsmodel.h index 2d37260217210..1a8a186a4037d 100644 --- a/src/gui/tray/trayaccountappsmodel.h +++ b/src/gui/tray/trayaccountappsmodel.h @@ -17,7 +17,7 @@ namespace OCC { class TrayAccountAppsModel : public QAbstractListModel { Q_OBJECT - Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) public: static TrayAccountAppsModel *instance(); ~TrayAccountAppsModel() override = default; @@ -31,7 +31,6 @@ class TrayAccountAppsModel : public QAbstractListModel Q_ENUM(Roles) [[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_SIGNALS: From 467c37aa29a9bfdbdb9c27a2fadd439c5d3f5392 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 7 Jul 2026 18:26:12 +0200 Subject: [PATCH 14/19] fix: app list svg rendering Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 49 +++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index ee27ca0d136e4..4296f3aa2da18 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -28,6 +28,7 @@ static const CGFloat kAvatarSize = 34.0; static const CGFloat kTopPadding = 4.0; static const CGFloat kActionHeight = 26.0; +static const CGFloat kActionIconSize = 18.0; static const CGFloat kPreviewActionHeight = 52.0; static const CGFloat kDetailedPreviewActionHeight = 58.0; static const CGFloat kActionVerticalPadding = 8.0; @@ -87,13 +88,35 @@ static NSPoint clampedPopupOrigin(const NSPoint origin, const NSSize size, const return NSScreen.mainScreen ?: NSScreen.screens.firstObject; } -static QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url) +static QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url, const QSize &requestedSize) { - if (!accountState || !accountState->account() || !url.isValid() || url.scheme().isEmpty()) { + if (!accountState || !accountState->account() || !url.isValid() || url.scheme().isEmpty() || !requestedSize.isValid()) { return {}; } - return QStringLiteral("%1:%2").arg(accountState->account()->id(), url.toString()); + return QStringLiteral("%1:%2x%3:%4").arg( + accountState->account()->id(), + QString::number(requestedSize.width()), + QString::number(requestedSize.height()), + url.toString()); +} + +static qreal backingScaleFactorForWindow(NSWindow *window) +{ + auto scale = window.screen.backingScaleFactor; + if (scale <= 0.0) { + scale = NSScreen.mainScreen.backingScaleFactor; + } + return scale > 0.0 ? scale : 1.0; +} + +static QSize pixelSizeForPointSize(const CGFloat pointSize, const qreal scale) +{ + auto pixelSize = static_cast(pointSize * scale + 0.5); + if (pixelSize < 1) { + pixelSize = 1; + } + return QSize(pixelSize, pixelSize); } static void addOwnedArrangedSubview(NSStackView *stack, NSView *view) @@ -243,6 +266,7 @@ static void positionPopupFromRow(NSPanel *popup, NSView *row) static NSImage *nsImageFromQImage(const QImage &qimg) { if (qimg.isNull()) return nil; + const auto devicePixelRatio = qimg.devicePixelRatio() > 0.0 ? qimg.devicePixelRatio() : 1.0; const QImage rgba = qimg.convertToFormat(QImage::Format_RGBA8888); NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:nullptr @@ -260,7 +284,9 @@ static void positionPopupFromRow(NSPanel *popup, NSView *row) return nil; } memcpy(rep.bitmapData, rgba.constBits(), (size_t)rgba.bytesPerLine() * rgba.height()); - NSImage *img = [[NSImage alloc] initWithSize:NSMakeSize(rgba.width(), rgba.height())]; + const auto imageSize = NSMakeSize(rgba.width() / devicePixelRatio, rgba.height() / devicePixelRatio); + rep.size = imageSize; + NSImage *img = [[NSImage alloc] initWithSize:imageSize]; [img addRepresentation:rep]; [rep release]; return [img autorelease]; @@ -795,8 +821,8 @@ - (instancetype)initWithTitle:(NSString *)title [constraints addObjectsFromArray:@[ [_iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], [_iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], - [_iconView.widthAnchor constraintEqualToConstant:18.0], - [_iconView.heightAnchor constraintEqualToConstant:18.0], + [_iconView.widthAnchor constraintEqualToConstant:kActionIconSize], + [_iconView.heightAnchor constraintEqualToConstant:kActionIconSize], ]]; if (isPreviewRow) { [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; @@ -813,7 +839,7 @@ - (instancetype)initWithTitle:(NSString *)title auto availableTextWidth = width - (kHPad * 2.0); if (icon) { - availableTextWidth -= 18.0 + 8.0; + availableTextWidth -= kActionIconSize + 8.0; } if (showsSubmenuIndicator) { availableTextWidth -= 8.0 + 8.0; @@ -1224,12 +1250,14 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner : OCC::AccountStatePtr{}; auto fallbackIcon = [[NSImage imageWithSystemSymbolName:@"app" accessibilityDescription:nil] imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightRegular]]; + const auto appIconRequestedSize = pixelSizeForPointSize(kActionIconSize, backingScaleFactorForWindow(self)); + const auto appIconDevicePixelRatio = static_cast(appIconRequestedSize.width()) / kActionIconSize; addOwnedArrangedSubview(_stack, [[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(); const auto appIconUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl(); - const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl); + const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl, appIconRequestedSize); auto appIcon = nsImageFromQUrl(appIconUrl); if (!appIcon && !appIconCacheKey.isEmpty() && s_remoteAppIconCache.contains(appIconCacheKey)) { appIcon = nsImageFromQImage(s_remoteAppIconCache.value(appIconCacheKey)); @@ -1248,9 +1276,10 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner if (!appIcon && !appIconCacheKey.isEmpty()) { auto retainedRow = [actionRow retain]; auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); - QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow, appIconCacheKey](const QByteArray &iconData) { - const auto image = qImageFromImageData(iconData, QSize(18, 18)); + QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow, appIconCacheKey, appIconRequestedSize, appIconDevicePixelRatio](const QByteArray &iconData) { + auto image = qImageFromImageData(iconData, appIconRequestedSize); if (!image.isNull()) { + image.setDevicePixelRatio(appIconDevicePixelRatio); s_remoteAppIconCache.insert(appIconCacheKey, image); [retainedRow setIcon:nsImageFromQImage(image)]; } From 00c21caa4ca47782aca1f8b1c3971a4484d3f546 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 7 Jul 2026 09:19:23 +0000 Subject: [PATCH 15/19] refactor(qml): use Style properties and create wizard component. Remove hardcoded sizes, radius and margins, use Style properties instead. Extract the shared window flags, palette and background of the wizard window into a new WizardStyledWindow base component. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Camila Ayres --- resources.qrc | 1 + src/gui/ActivitiesWindow.qml | 23 +------- src/gui/AssistantWindow.qml | 23 +------- src/gui/EmojiPicker.qml | 8 +-- src/gui/UserStatusWindow.qml | 61 +++++++--------------- src/gui/WizardStyledWindow.qml | 35 +++++++++++++ src/gui/wizard/qml/AccountWizardWindow.qml | 10 ++-- src/gui/wizard/qml/ServerPage.qml | 20 +++---- src/gui/wizard/qml/SyncOptionsPage.qml | 10 ++-- src/gui/wizard/qml/WizardButton.qml | 20 +++---- theme/Style/Style.qml | 17 +++--- 11 files changed, 100 insertions(+), 128 deletions(-) create mode 100644 src/gui/WizardStyledWindow.qml diff --git a/resources.qrc b/resources.qrc index 118fb21077f17..122a9487a4d21 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,6 +1,7 @@ src/gui/UserStatusMessageView.qml + src/gui/WizardStyledWindow.qml src/gui/UserStatusWindow.qml src/gui/WindowAccountHeader.qml src/gui/ActivitiesWindow.qml diff --git a/src/gui/ActivitiesWindow.qml b/src/gui/ActivitiesWindow.qml index 18b1e916dac35..77d9c7531f00b 100644 --- a/src/gui/ActivitiesWindow.qml +++ b/src/gui/ActivitiesWindow.qml @@ -10,7 +10,7 @@ import QtQuick.Layouts import Style import "./tray" -ApplicationWindow { +WizardStyledWindow { id: root property int userIndex: -1 @@ -18,32 +18,11 @@ ApplicationWindow { 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 diff --git a/src/gui/AssistantWindow.qml b/src/gui/AssistantWindow.qml index ee9e9024aeb5e..17fcd59838aff 100644 --- a/src/gui/AssistantWindow.qml +++ b/src/gui/AssistantWindow.qml @@ -11,7 +11,7 @@ import Style import "./tray" import "./wizard/qml" -ApplicationWindow { +WizardStyledWindow { id: root property int userIndex: -1 @@ -22,32 +22,11 @@ ApplicationWindow { || 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) { diff --git a/src/gui/EmojiPicker.qml b/src/gui/EmojiPicker.qml index 8e6dcb2b61740..841dd2da42a47 100644 --- a/src/gui/EmojiPicker.qml +++ b/src/gui/EmojiPicker.qml @@ -73,16 +73,16 @@ ColumnLayout { text: root.searchText topPadding: 0 bottomPadding: 0 - leftPadding: 10 - rightPadding: 10 + leftPadding: Style.standardSpacing + rightPadding: Style.standardSpacing font.pixelSize: Style.pixelSize + 2 verticalAlignment: TextInput.AlignVCenter onTextChanged: root.searchText = text background: Rectangle { - radius: 8 + radius: Style.mediumRoundedButtonRadius color: palette.base - border.width: 1 + border.width: Style.normalBorderWidth border.color: searchField.activeFocus ? Style.ncBlue : palette.dark } } diff --git a/src/gui/UserStatusWindow.qml b/src/gui/UserStatusWindow.qml index 75021c3839c3a..34bed31098e05 100644 --- a/src/gui/UserStatusWindow.qml +++ b/src/gui/UserStatusWindow.qml @@ -12,41 +12,18 @@ import Style import "./tray" import "./wizard/qml" -ApplicationWindow { +WizardStyledWindow { 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 - } + width: Style.userStatusWindowWidth + height: Style.userStatusWindowHeight + minimumWidth: Style.wizardStandaloneWindowMinimumWidth + minimumHeight: Style.userStatusWindowMinimumHeight NC.UserStatusSelectorModel { id: statusModel @@ -108,7 +85,7 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent anchors.margins: frame.windowMargin - spacing: root.sectionSpacing + spacing: Style.wizardSectionSpacing ColumnLayout { Layout.fillWidth: true @@ -172,7 +149,7 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: root.rowSpacing EnforcedPlainTextLabel { Layout.fillWidth: true @@ -187,15 +164,15 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: root.rowSpacing Button { id: emojiButton readonly property string fallbackEmoji: "😀" - Layout.preferredWidth: 36 - Layout.preferredHeight: 36 + Layout.preferredWidth: Style.wizardFooterButtonHeight + Layout.preferredHeight: Style.wizardFooterButtonHeight padding: 0 enabled: root.statusLoaded text: statusModel.userStatusEmoji.length > 0 @@ -223,7 +200,7 @@ ApplicationWindow { id: statusMessageField Layout.fillWidth: true - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight placeholderText: qsTr("What is your status?") enabled: root.statusLoaded selectByMouse: true @@ -251,9 +228,9 @@ ApplicationWindow { background: Rectangle { color: Style.wizardWindowBackground - border.width: 1 + border.width: Style.normalBorderWidth border.color: Style.wizardFieldBorder - radius: 8 + radius: Style.mediumRoundedButtonRadius } EmojiPicker { @@ -281,7 +258,7 @@ ApplicationWindow { Column { width: predefinedStatusesScrollView.availableWidth - spacing: 2 + spacing: Style.extraSmallSpacing Repeater { model: statusModel.predefinedStatuses @@ -305,11 +282,11 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true - spacing: 8 + spacing: root.rowSpacing EnforcedPlainTextLabel { Layout.preferredWidth: implicitWidth - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight text: qsTr("Clear status after") color: Style.wizardPrimaryText font.pixelSize: Style.pixelSize + 3 @@ -321,7 +298,7 @@ ApplicationWindow { id: clearAtComboBox Layout.fillWidth: true - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight leftPadding: 12 rightPadding: 32 topPadding: 0 @@ -344,9 +321,9 @@ ApplicationWindow { } background: Rectangle { - radius: 8 + radius: Style.mediumRoundedButtonRadius color: Style.wizardFieldBackground - border.width: 1 + border.width: Style.normalBorderWidth border.color: clearAtComboBox.activeFocus ? Style.ncBlue : Style.wizardFieldBorder } } diff --git a/src/gui/WizardStyledWindow.qml b/src/gui/WizardStyledWindow.qml new file mode 100644 index 0000000000000..81fb6e618fb2a --- /dev/null +++ b/src/gui/WizardStyledWindow.qml @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic + +import Style + +ApplicationWindow { + id: root + + LayoutMirroring.enabled: Application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + 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 + } +} diff --git a/src/gui/wizard/qml/AccountWizardWindow.qml b/src/gui/wizard/qml/AccountWizardWindow.qml index b1f8597d7f883..94d1ed856caa7 100644 --- a/src/gui/wizard/qml/AccountWizardWindow.qml +++ b/src/gui/wizard/qml/AccountWizardWindow.qml @@ -11,8 +11,6 @@ import com.nextcloud.desktopclient import Style import "../../tray" -import "../../tray" - ApplicationWindow { id: root @@ -141,16 +139,16 @@ ApplicationWindow { property bool retryHttpOnly: false modal: true - width: 420 + width: Style.wizardDialogMaximumWidth height: secureConnectionContent.implicitHeight + topPadding + bottomPadding - padding: 24 + padding: Style.wizardWindowMargin x: Math.round((root.width - width) / 2) y: Math.round((root.height - height) / 2) header: null footer: null background: Rectangle { - radius: 12 + radius: Style.wizardDialogRadius color: Style.wizardWindowBackground } @@ -332,7 +330,7 @@ ApplicationWindow { font.weight: Font.Medium Layout.fillWidth: true Layout.preferredWidth: 1 - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight onClicked: root.controller.openProxySettings() MouseArea { diff --git a/src/gui/wizard/qml/ServerPage.qml b/src/gui/wizard/qml/ServerPage.qml index 79899c6833912..06a55798d18e0 100644 --- a/src/gui/wizard/qml/ServerPage.qml +++ b/src/gui/wizard/qml/ServerPage.qml @@ -19,10 +19,10 @@ Item { ColumnLayout { anchors.fill: parent - anchors.leftMargin: 24 - anchors.rightMargin: 24 - anchors.topMargin: 24 - anchors.bottomMargin: 24 + anchors.leftMargin: Style.wizardWindowMargin + anchors.rightMargin: Style.wizardWindowMargin + anchors.topMargin: Style.wizardWindowMargin + anchors.bottomMargin: Style.wizardWindowMargin spacing: 4 EnforcedPlainTextLabel { @@ -95,8 +95,8 @@ Item { indicator: Image { x: serverSelector.width - width - 12 y: Math.round((serverSelector.height - height) / 2) - width: 16 - height: 16 + width: Style.smallIconSize + height: Style.smallIconSize source: "image://svgimage-custom-color/caret-down.svg/" + root.primaryTextColor rotation: serverSelector.popup.visible ? 180 : 0 opacity: serverSelector.enabled ? 1 : 0.45 @@ -111,9 +111,9 @@ Item { } background: Rectangle { - radius: 8 + radius: Style.mediumRoundedButtonRadius color: Style.wizardFieldBackground - border.width: 1 + border.width: Style.normalBorderWidth border.color: serverSelector.activeFocus || serverSelector.popup.visible ? Style.ncBlue : Style.wizardFieldBorder @@ -167,9 +167,9 @@ Item { } background: Rectangle { - radius: 8 + radius: Style.mediumRoundedButtonRadius color: Style.wizardFieldBackground - border.width: 1 + border.width: Style.normalBorderWidth border.color: Style.wizardSecondaryButtonBorder } } diff --git a/src/gui/wizard/qml/SyncOptionsPage.qml b/src/gui/wizard/qml/SyncOptionsPage.qml index 65393e21636a7..b06206a378693 100644 --- a/src/gui/wizard/qml/SyncOptionsPage.qml +++ b/src/gui/wizard/qml/SyncOptionsPage.qml @@ -26,7 +26,7 @@ Item { anchors.rightMargin: 28 anchors.topMargin: 40 anchors.bottomMargin: 16 - spacing: 2 + spacing: Style.extraSmallSpacing Item { Layout.preferredWidth: 80 @@ -132,7 +132,7 @@ Item { Rectangle { Layout.fillWidth: true - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight radius: 6 border.width: 1 border.color: root.controller.localSyncFolderError === "" ? Style.wizardRowBorder : Style.wizardErrorBorder @@ -140,8 +140,8 @@ Item { EnforcedPlainTextLabel { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 + anchors.leftMargin: Style.standardSpacing + anchors.rightMargin: Style.standardSpacing verticalAlignment: Text.AlignVCenter text: root.controller.localSyncFolderDisplay color: root.primaryTextColor @@ -153,7 +153,7 @@ Item { WizardButton { text: qsTr("Choose") Layout.preferredWidth: 96 - Layout.preferredHeight: 36 + Layout.preferredHeight: Style.wizardFooterButtonHeight enabled: root.controller.canUseClassicSync onClicked: root.controller.chooseLocalSyncFolder() } diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index d4367fe2031ac..fe8de4c521ab5 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -23,7 +23,7 @@ BasicControls.Button { readonly property color disabledColor: Style.wizardDisabledButtonBackground readonly property color disabledBorderColor: Style.wizardDisabledButtonBorder - implicitHeight: 36 + implicitHeight: Style.wizardFooterButtonHeight leftPadding: 18 rightPadding: 18 font.pixelSize: Style.pixelSize + 3 @@ -44,10 +44,10 @@ BasicControls.Button { Image { visible: root.iconSource !== "" && root.iconBeforeText source: root.iconSource - sourceSize.width: 16 - sourceSize.height: 16 - width: visible ? 16 : 0 - height: 16 + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + width: visible ? Style.smallIconSize : 0 + height: Style.smallIconSize anchors.verticalCenter: parent.verticalCenter fillMode: Image.PreserveAspectFit } @@ -65,10 +65,10 @@ BasicControls.Button { Image { visible: root.iconSource !== "" && !root.iconBeforeText source: root.iconSource - sourceSize.width: 16 - sourceSize.height: 16 - width: visible ? 16 : 0 - height: 16 + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + width: visible ? Style.smallIconSize : 0 + height: Style.smallIconSize anchors.verticalCenter: parent.verticalCenter fillMode: Image.PreserveAspectFit } @@ -76,7 +76,7 @@ BasicControls.Button { } background: Rectangle { - radius: 8 + radius: Style.mediumRoundedButtonRadius border.width: root.primary ? 0 : 1 border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor color: { diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index b333e037b7c09..8ce44934defd5 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -95,7 +95,7 @@ QtObject { 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 @@ -119,11 +119,11 @@ 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 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 @@ -146,8 +146,11 @@ QtObject { readonly property int activitiesWindowHeight: 700 readonly property int assistantWindowWidth: 640 readonly property int assistantWindowHeight: 620 + readonly property int userStatusWindowWidth: 560 + readonly property int userStatusWindowHeight: 700 + readonly property int userStatusWindowMinimumHeight: 560 - property int minActivityHeight: variableSize(32) + property int minActivityHeight: variableSize(32) property int minimumScrollBarWidth: 12 property real minimumScrollBarThumbSize: 0 From 6c153b82174d346a78bf373df530d6527bbf3260 Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 7 Jul 2026 16:48:12 +0000 Subject: [PATCH 16/19] fix(tray): make TrayAccountAppsModel::Roles an unscoped enum. The role enumerators were declared as a scoped enum (enum class) while the tray popup call sites used the unscoped form and relied on implicit int conversion, which broke the macOS build. Switch to an unscoped enum so the enumerators live in class scope and convert to int, matching the call sites and the sibling role enums, and drop the now-redundant casts. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/gui/tray/trayaccountappsmodel.cpp | 16 ++++++++-------- src/gui/tray/trayaccountappsmodel.h | 2 +- src/gui/trayaccountpopup_qt.cpp | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp index a97a27890b172..88cd6c473272b 100644 --- a/src/gui/tray/trayaccountappsmodel.cpp +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -93,15 +93,15 @@ QVariant TrayAccountAppsModel::data(const QModelIndex &index, const int role) co return result; } - if (role >= static_cast(Roles::NameRole) && role < static_cast(Roles::IconUrlRole)) { - switch (static_cast(role)) { - case Roles::NameRole: + if (role >= NameRole && role < IconUrlRole) { + switch (role) { + case NameRole: result = _apps[index.row()]->name(); break; - case Roles::UrlRole: + case UrlRole: result = _apps[index.row()]->url(); break; - case Roles::IconUrlRole: + case IconUrlRole: result = _apps[index.row()]->iconUrl().toString(); break; } @@ -113,9 +113,9 @@ QVariant TrayAccountAppsModel::data(const QModelIndex &index, const int role) co QHash TrayAccountAppsModel::roleNames() const { return { - { static_cast(Roles::NameRole), "appName" }, - { static_cast(Roles::UrlRole), "appUrl" }, - { static_cast(Roles::IconUrlRole), "appIconUrl" }, + { NameRole, "appName" }, + { UrlRole, "appUrl" }, + { IconUrlRole, "appIconUrl" }, }; } diff --git a/src/gui/tray/trayaccountappsmodel.h b/src/gui/tray/trayaccountappsmodel.h index 1a8a186a4037d..ec73b59506fdd 100644 --- a/src/gui/tray/trayaccountappsmodel.h +++ b/src/gui/tray/trayaccountappsmodel.h @@ -22,7 +22,7 @@ class TrayAccountAppsModel : public QAbstractListModel static TrayAccountAppsModel *instance(); ~TrayAccountAppsModel() override = default; - enum class Roles { + enum Roles { NameRole = Qt::UserRole + 1, UrlRole, IconUrlRole, diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 34234d362fc7c..637255855168a 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -556,9 +556,9 @@ bool populateAppsMenu(QMenu *menu, const int userId) for (auto row = 0; row < appCount; ++row) { const auto appIndex = appsModel->index(row); - const auto appName = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::NameRole)).toString(); - const auto appUrl = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::UrlRole)).toUrl(); - const auto appIconUrl = appsModel->data(appIndex, static_cast(TrayAccountAppsModel::Roles::IconUrlRole)).toUrl(); + const auto appName = appsModel->data(appIndex, TrayAccountAppsModel::NameRole).toString(); + const auto appUrl = appsModel->data(appIndex, TrayAccountAppsModel::UrlRole).toUrl(); + const auto appIconUrl = appsModel->data(appIndex, TrayAccountAppsModel::IconUrlRole).toUrl(); auto appIcon = iconFromUrl(appIconUrl); if (appIcon.isNull()) { appIcon = templateBlackThemeIcon(QStringLiteral("more-apps.svg"), appIconSize, appIconPalette); From 7bbcf3109939cd2b580e8b892b9e00d53c14fc8f Mon Sep 17 00:00:00 2001 From: Camila Ayres Date: Tue, 7 Jul 2026 16:51:44 +0000 Subject: [PATCH 17/19] fix(tray): include IconUrlRole in TrayAccountAppsModel::data role range. The role range guard used role < IconUrlRole, which excluded IconUrlRole itself, so case IconUrlRole was unreachable and data() returned an empty QVariant for it. As a result the account apps popup never received app icon URLs. Use <= so the icon role is served. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Camila Ayres --- src/gui/tray/trayaccountappsmodel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/tray/trayaccountappsmodel.cpp b/src/gui/tray/trayaccountappsmodel.cpp index 88cd6c473272b..2c0a2948da88b 100644 --- a/src/gui/tray/trayaccountappsmodel.cpp +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -93,7 +93,7 @@ QVariant TrayAccountAppsModel::data(const QModelIndex &index, const int role) co return result; } - if (role >= NameRole && role < IconUrlRole) { + if (role >= NameRole && role <= IconUrlRole) { switch (role) { case NameRole: result = _apps[index.row()]->name(); From e20176f6a019441f3d54d7f9b427ffbe41872a35 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 7 Jul 2026 20:34:12 +0200 Subject: [PATCH 18/19] fix: string cleanups Signed-off-by: Rello --- src/gui/macOS/trayaccountpopup_mac.mm | 9 +++++---- src/gui/tray/activitydata.cpp | 2 +- src/gui/trayaccountpopup_qt.cpp | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 4296f3aa2da18..d4e092069046b 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -1255,14 +1255,15 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner addOwnedArrangedSubview(_stack, [[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(); - const auto appIconUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl(); + const auto appName = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::NameRole)).toString(); + const auto appUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::UrlRole)).toUrl(); + const auto appIconUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::IconUrlRole)).toUrl(); const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl, appIconRequestedSize); auto appIcon = nsImageFromQUrl(appIconUrl); if (!appIcon && !appIconCacheKey.isEmpty() && s_remoteAppIconCache.contains(appIconCacheKey)) { appIcon = nsImageFromQImage(s_remoteAppIconCache.value(appIconCacheKey)); } - auto actionRow = [[NCActionRow alloc] initWithTitle:appsModel->data(appIndex, OCC::TrayAccountAppsModel::NameRole).toString().toNSString() + auto actionRow = [[NCActionRow alloc] initWithTitle:appName.toNSString() icon:appIcon != nil ? appIcon : fallbackIcon width:kAppsPopupWidth enabled:YES @@ -1569,7 +1570,7 @@ - (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshAc [weakSelf hideAppsPopup]; }]); if (assistantEnabled) { - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246").toNSString() + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Open Assistant").toNSString() width:kAccountActionsPopupWidth enabled:YES action:^{ diff --git a/src/gui/tray/activitydata.cpp b/src/gui/tray/activitydata.cpp index 2405610661b71..7733429732252 100644 --- a/src/gui/tray/activitydata.cpp +++ b/src/gui/tray/activitydata.cpp @@ -300,7 +300,7 @@ QVariantList Activity::notificationPreviewActions() const if (isDismissableActivity()) { actions.push_back(notificationPreviewAction( - QCoreApplication::translate("ActivityItemContent", "Dismiss"), + QCoreApplication::translate("ActivityItemContent", "Confirm"), QStringLiteral("dismiss"), -1, QByteArrayLiteral("DELETE"))); diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 637255855168a..bc882330c97e2 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -738,7 +738,7 @@ void populateAccountMenu(QMenu *menu, const int userId, const bool fetchActivity if (assistantEnabled) { const auto assistantAction = addMenuAction(menu, templateBlackThemeIcon(QStringLiteral("nc-assistant-app.svg"), menuIconSize, menuIconPalette), - QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246")); + QCoreApplication::translate("MainWindow", "Open Assistant")); QObject::connect(assistantAction, &QAction::triggered, assistantAction, [userId] { openAssistantForUser(userId); }); From 0f6f6d6e5f4d0c53d3c5b3ceea60133ec66d51a2 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Wed, 8 Jul 2026 13:06:00 +0200 Subject: [PATCH 19/19] chore(macOS): Split up trayaccountpopup_mac.mm into dedicated source code files and added documentation Signed-off-by: Iva Horn --- AGENTS.md | 23 + src/gui/CMakeLists.txt | 35 +- .../trayaccountpopup/ncaccountactionspopup.h | 38 + .../trayaccountpopup/ncaccountactionspopup.mm | 385 ++++ src/gui/macOS/trayaccountpopup/ncaccountrow.h | 32 + .../macOS/trayaccountpopup/ncaccountrow.mm | 69 + src/gui/macOS/trayaccountpopup/ncactionrow.h | 101 + src/gui/macOS/trayaccountpopup/ncactionrow.mm | 388 ++++ .../macOS/trayaccountpopup/ncalertboxrow.h | 23 + .../macOS/trayaccountpopup/ncalertboxrow.mm | 114 + src/gui/macOS/trayaccountpopup/ncappspopup.h | 23 + src/gui/macOS/trayaccountpopup/ncappspopup.mm | 116 + src/gui/macOS/trayaccountpopup/nchoverview.h | 19 + src/gui/macOS/trayaccountpopup/nchoverview.mm | 48 + .../ncnotificationactionspopup.h | 25 + .../ncnotificationactionspopup.mm | 88 + .../trayaccountpopup/ncpointinghandbutton.h | 14 + .../trayaccountpopup/ncpointinghandbutton.mm | 16 + .../trayaccountpopup/ncsectionheaderrow.h | 16 + .../trayaccountpopup/ncsectionheaderrow.mm | 35 + src/gui/macOS/trayaccountpopup/ncspacerview.h | 18 + .../macOS/trayaccountpopup/ncspacerview.mm | 29 + .../macOS/trayaccountpopup/ncstaticinforow.h | 22 + .../macOS/trayaccountpopup/ncstaticinforow.mm | 65 + src/gui/macOS/trayaccountpopup/nctraypopup.h | 36 + src/gui/macOS/trayaccountpopup/nctraypopup.mm | 314 +++ .../trayaccountpopup/trayaccountpopup_mac.mm | 90 + .../trayaccountpopupimageutils.h | 47 + .../trayaccountpopupimageutils.mm | 135 ++ .../trayaccountpopupmetrics.h | 45 + .../trayaccountpopupviewutils.h | 42 + .../trayaccountpopupviewutils.mm | 172 ++ src/gui/macOS/trayaccountpopup_mac.mm | 2031 ----------------- src/gui/trayaccountpopup_qt.cpp | 2 +- 34 files changed, 2623 insertions(+), 2033 deletions(-) create mode 100644 src/gui/macOS/trayaccountpopup/ncaccountactionspopup.h create mode 100644 src/gui/macOS/trayaccountpopup/ncaccountactionspopup.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncaccountrow.h create mode 100644 src/gui/macOS/trayaccountpopup/ncaccountrow.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncactionrow.h create mode 100644 src/gui/macOS/trayaccountpopup/ncactionrow.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncalertboxrow.h create mode 100644 src/gui/macOS/trayaccountpopup/ncalertboxrow.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncappspopup.h create mode 100644 src/gui/macOS/trayaccountpopup/ncappspopup.mm create mode 100644 src/gui/macOS/trayaccountpopup/nchoverview.h create mode 100644 src/gui/macOS/trayaccountpopup/nchoverview.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.h create mode 100644 src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncpointinghandbutton.h create mode 100644 src/gui/macOS/trayaccountpopup/ncpointinghandbutton.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncsectionheaderrow.h create mode 100644 src/gui/macOS/trayaccountpopup/ncsectionheaderrow.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncspacerview.h create mode 100644 src/gui/macOS/trayaccountpopup/ncspacerview.mm create mode 100644 src/gui/macOS/trayaccountpopup/ncstaticinforow.h create mode 100644 src/gui/macOS/trayaccountpopup/ncstaticinforow.mm create mode 100644 src/gui/macOS/trayaccountpopup/nctraypopup.h create mode 100644 src/gui/macOS/trayaccountpopup/nctraypopup.mm create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopup_mac.mm create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.h create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.mm create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopupmetrics.h create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.h create mode 100644 src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.mm delete mode 100644 src/gui/macOS/trayaccountpopup_mac.mm diff --git a/AGENTS.md b/AGENTS.md index 6adc22e56564a..f79a004d4ef98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,17 @@ See [HowToApplyALicense.md](https://github.com/nextcloud/server/blob/master/cont Avoid creating source files that implement multiple types; instead, place each type in its own dedicated source file. +### Documentation comments + +Documentation must be trustworthy: only write a comment you can back with the implementation in front of you. **A wrong or overreaching comment is worse than none** - when a behaviour is unclear or you cannot state it with confidence, leave it undocumented. This is the same verifiability rule that governs commit messages and PR text, applied to code comments. + +- **Style.** Use Doxygen `/** @brief ... */` blocks for types and their members - the established convention across this codebase. Use a trailing `//!< ...` for a single instance variable or field, and plain `//` for free helper functions and file-local statics. +- **Document the type and each declared member.** For a type, state what it is and how it behaves. For each public property, method or protocol callback, state what it does. Add `@param` entries only where the meaning is not obvious from the name - in particular, spell out what `nil`, `NO`, an empty string or `0` does, and which event triggers a callback block. +- **Do not overreach.** Describe what the code actually does, not what it looks like it should do. Avoid absolute claims the implementation does not guarantee - for example, do not write "keeps the window on screen" for a helper that only best-effort clamps and can still overflow, or "loads a remote URL" for one that only reads local files. +- **Overload sets.** When a type has many overloaded initializers or methods that funnel into one, fully document the designated one (with its `@param` list) and simply mark the rest as convenience overloads rather than repeating the text. +- **Comment members selectively.** Document instance variables and file-local statics whose purpose is not obvious from their name and type; leave self-explanatory ones (a backing `_stack`, a counter) uncommented to avoid noise. In Objective-C(++), instance variables live in the `@implementation` block, so their comments belong there, not in the header. +- **Verify before you trust it.** Re-read the implementation and confirm every comment is literally true before considering the work done. + ## Commit and Pull Request Guidelines ### Commit format @@ -148,6 +159,18 @@ The following details are important and only relevant when working on the deskto - When writing code in Swift, respect strict concurrency rules and Swift 6 compatibility. - Manage memory explicitly and manually when writing or updating code located under `./src`. For example, do not use features like `__weak` from automatic reference counting in Objective-C because ARC is not used in this project. +#### Code organization + +These conventions apply to the C++, Objective-C and Objective-C++ (and their Qt-facing) code under `./src`, and macOS code under `src/gui/macOS` in particular. Follow them both when adding code and when a file has grown into an unmaintainable catch-all that must be split. + +- **One type per file.** Each Objective-C class or C++ type gets its own dedicated header and implementation pair (for example `ncactionrow.h` + `ncactionrow.mm`). Never accumulate several classes in a single translation unit. Objective-C class file names are the class name lowercased, without the `NC`/type prefix stripped (`NCActionRow` → `ncactionrow`). +- **Keep the public boundary C++/Qt-only.** The seam between a macOS module and the rest of the app must expose only C++ and Qt types (for example the `showMacOSTrayPopup(const QRect &)` entry point declared in `systray.h`). *Internal* Objective-C++ headers, by contrast, may freely declare Objective-C classes and expose `NS*` types — see `fileproviderxpc_mac_utils.h` — because their only consumers are other Objective-C++ files in the same module. +- **Group a feature's files in a subdirectory.** When a split produces more than a handful of files, place them in a feature subdirectory (for example `src/gui/macOS/trayaccountpopup/`), mirroring how `src/gui` already uses `tray/`, `wizard/` and `integration/`. +- **Collect cohesive free helpers and constants into clearly-scoped support files.** Shared free functions belong in a namespaced `*utils` header/implementation pair (for example `namespace OCC::Mac::TrayPopupViewUtils`, following the existing `OCC::Mac::FileProviderXPCUtils`); shared layout constants belong in a `*metrics` header. Lightweight declarations such as a single block alias or a constant do **not** each need their own file — the one-type-per-file rule targets classes, not aliases. +- **Keep single-use helpers next to their sole user.** A file-static helper or cache used by exactly one class stays in that class's implementation file rather than being promoted to a shared header. +- **Objective-C instance variables live in the `@implementation` block**, not the header — this is the project's Objective-C equivalent of the PIMPL pattern and keeps a type's storage private to its implementation. +- **Remove dead code as part of the move.** When reorganizing an existing file, drop functions and members that are defined but never referenced instead of carrying them along. + #### Logging These instructions are restricted to `./shell_integration/MacOSX/NextcloudIntegration/FileProviderExt` and `./shell_integration/MacOSX/NextcloudFileProviderKit`: diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 82200ff5e74c7..545bab39cdb86 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -275,7 +275,40 @@ IF( APPLE ) list(APPEND client_SRCS cocoainitializer_mac.mm) list(APPEND client_SRCS systray_mac_common.mm) list(APPEND client_SRCS notificationsoundplayer_mac.mm) - list(APPEND client_SRCS macOS/trayaccountpopup_mac.mm) + list(APPEND client_SRCS + # macOS tray account popup: one type per header/implementation pair. + # Shared foundation first, then the base hover view, rows, popups and the + # public entry point (OCC::showMacOSTrayPopup / hideMacOSTrayPopup). + macOS/trayaccountpopup/trayaccountpopupmetrics.h + macOS/trayaccountpopup/trayaccountpopupimageutils.h + macOS/trayaccountpopup/trayaccountpopupimageutils.mm + macOS/trayaccountpopup/trayaccountpopupviewutils.h + macOS/trayaccountpopup/trayaccountpopupviewutils.mm + macOS/trayaccountpopup/nchoverview.h + macOS/trayaccountpopup/nchoverview.mm + macOS/trayaccountpopup/ncaccountrow.h + macOS/trayaccountpopup/ncaccountrow.mm + macOS/trayaccountpopup/ncactionrow.h + macOS/trayaccountpopup/ncactionrow.mm + macOS/trayaccountpopup/ncsectionheaderrow.h + macOS/trayaccountpopup/ncsectionheaderrow.mm + macOS/trayaccountpopup/ncstaticinforow.h + macOS/trayaccountpopup/ncstaticinforow.mm + macOS/trayaccountpopup/ncpointinghandbutton.h + macOS/trayaccountpopup/ncpointinghandbutton.mm + macOS/trayaccountpopup/ncalertboxrow.h + macOS/trayaccountpopup/ncalertboxrow.mm + macOS/trayaccountpopup/ncspacerview.h + macOS/trayaccountpopup/ncspacerview.mm + macOS/trayaccountpopup/ncappspopup.h + macOS/trayaccountpopup/ncappspopup.mm + macOS/trayaccountpopup/ncnotificationactionspopup.h + macOS/trayaccountpopup/ncnotificationactionspopup.mm + macOS/trayaccountpopup/ncaccountactionspopup.h + macOS/trayaccountpopup/ncaccountactionspopup.mm + macOS/trayaccountpopup/nctraypopup.h + macOS/trayaccountpopup/nctraypopup.mm + macOS/trayaccountpopup/trayaccountpopup_mac.mm) list(APPEND client_SRCS macOS/singleinstancemanager_mac.mm) list(APPEND client_SRCS systray_mac_usernotifications.mm) diff --git a/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.h b/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.h new file mode 100644 index 0000000000000..3b0a858482500 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.h @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +#include + +@class NCTrayPopup; + +/** + * @brief The submenu shown for a single account. + * + * Lists the user status, "Reveal in Finder", the Assistant and Apps shortcuts, + * pending notifications and recent activity. Owns the apps and + * notification-actions sub-popups. + */ +@interface NCAccountActionsPopup : NSPanel +/** @brief Rebuilds the popup for the given account and refreshes its activity preview. */ +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; +/** + * @brief Rebuilds the popup for the given account. + * @param refreshActivities When NO, the activity preview is not re-fetched + * (used to rebuild in place when the model reports new data). + */ +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshActivities:(BOOL)refreshActivities; +/** @brief Whether the popup is currently visible and showing the given account. */ +- (BOOL)isShowingActivitiesForUserIndex:(int)userIndex; +/** @brief Removes the persistent highlight from the row whose submenu is currently open. */ +- (void)clearActiveSubmenuRow; +/** @brief Hides the apps and notification-actions sub-popups and clears the active submenu row. */ +- (void)hideAppsPopup; +/** @brief Shows the notification-actions sub-popup anchored to the given @c row. */ +- (void)showNotificationActionsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.mm b/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.mm new file mode 100644 index 0000000000000..9a46c80a257e6 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncaccountactionspopup.mm @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncaccountactionspopup.h" + +#import "ncactionrow.h" +#import "ncappspopup.h" +#import "ncnotificationactionspopup.h" +#import "ncsectionheaderrow.h" +#import "ncspacerview.h" +#import "ncstaticinforow.h" +#import "nctraypopup.h" +#import "trayaccountpopupimageutils.h" +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +#include "systray.h" +#include "tray/trayaccountappsmodel.h" +#include "tray/usermodel.h" + +#include +#include +#include + +using namespace OCC::Mac::TrayPopupImageUtils; +using namespace OCC::Mac::TrayPopupViewUtils; + +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 statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) +{ + const auto trimmedMessage = message.trimmed(); + return trimmedMessage.isEmpty() ? statusText(status) : trimmedMessage; +} + +static NSView *accountActionsSeparator(const CGFloat verticalMargin) +{ + auto separator = [[[NSBox alloc] init] autorelease]; + 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 autorelease]; +} + +static NSView *accountActionsSeparator() +{ + return accountActionsSeparator(kAccountHoverVerticalMargin); +} + +static NSView *compactAccountActionsSeparator() +{ + return accountActionsSeparator(kCompactSeparatorVerticalMargin); +} + +@implementation NCAccountActionsPopup { + NSStackView *_stack; + NCAppsPopup *_appsPopup; + NCNotificationActionsPopup *_notificationActionsPopup; + NCActionRow *_activeSubmenuRow; //!< The row whose sub-popup is currently shown, kept persistently highlighted. + __unsafe_unretained NCTrayPopup *_owner; + QMetaObject::Connection _recentActivitiesConnection; //!< Rebuilds the popup in place when the model reports new activity or notifications. + int _userIndex; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kAccountActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + _userIndex = -1; + _stack = configurePopupPanel(self); + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)dealloc +{ + [_appsPopup release]; + [_notificationActionsPopup release]; + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + } + [super dealloc]; +} + +- (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]; +} + +- (void)clearActiveSubmenuRow +{ + [_activeSubmenuRow setPersistentHighlight:NO]; + _activeSubmenuRow = nil; +} + +- (void)hideAppsPopup +{ + [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; +} + +- (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex +{ + if (!_appsPopup) { + _appsPopup = [[NCAppsPopup alloc] init]; + } + + [_notificationActionsPopup orderOut:nil]; + [_appsPopup populateForUserIndex:userIndex owner:_owner]; + [self clearActiveSubmenuRow]; + if ([row isKindOfClass:[NCActionRow class]]) { + _activeSubmenuRow = (NCActionRow *)row; + [_activeSubmenuRow setPersistentHighlight:YES]; + } + + positionPopupFromRow(_appsPopup, row); + [_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]; + } + + positionPopupFromRow(_notificationActionsPopup, row); + [_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 +{ + const auto preserveTopEdge = [self isVisible]; + const auto topEdge = NSMaxY(self.frame); + + _owner = owner; + _userIndex = userIndex; + [_appsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; + + clearStack(_stack); + + 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 serverHasUserStatus = model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); + const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() + && 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(); + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); + 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()); + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() + width:kAccountActionsPopupWidth]); + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() + icon:statusIcon + width:kAccountActionsPopupWidth + enabled:onlineStatusEnabled + action:^{ + [weakOwner openOnlineStatusForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + [_stack addArrangedSubview:accountActionsSeparator()]; + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Reveal in Finder").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openLocalFolderForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + if (assistantEnabled) { + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Open Assistant").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openAssistantForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "Apps").toNSString() + icon:nil + width:kAccountActionsPopupWidth + enabled:appsEnabled + action:^{} + hoverAction:^(NSView *row) { + [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; + } showsSubmenuIndicator:YES]); + + [_stack addArrangedSubview:accountActionsSeparator()]; + + const auto trayNotifications = model->data(userModelIndex, OCC::UserModel::TrayNotificationsRole).toList(); + if (!trayNotifications.isEmpty()) { + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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(); + addOwnedArrangedSubview(_stack, [[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:compactAccountActionsSeparator()]; + } + + addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Recent activity").toNSString() + width:kAccountActionsPopupWidth]); + const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); + if (recentActivities.isEmpty()) { + addOwnedArrangedSubview(_stack, [[NCStaticInfoRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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; + } + addOwnedArrangedSubview(_stack, [[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]; + }]); + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]); + + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kAccountActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + if (preserveTopEdge) { + frame.origin.y = topEdge - frame.size.height; + } + auto screen = self.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + if (screen) { + frame.origin = clampedPopupOrigin(frame.origin, frame.size, screen.visibleFrame); + } + [self setFrame:frame display:NO]; + [self invalidateShadow]; + + if (refreshActivities) { + model->fetchActivityPreview(userIndex); + } +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncaccountrow.h b/src/gui/macOS/trayaccountpopup/ncaccountrow.h new file mode 100644 index 0000000000000..9f4ba412fc8af --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncaccountrow.h @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import "nchoverview.h" + +@class NCAccountRow; + +/** + * @brief Receives click and hover callbacks from an NCAccountRow. + */ +@protocol NCAccountRowDelegate +/** @brief Sent when the row is clicked, passing the row's user index. */ +- (void)onAccountRowClicked:(int)index; +/** @brief Sent when the pointer enters the row. */ +- (void)onAccountRowHovered:(NCAccountRow *)row; +@end + +/** + * @brief A tray-popup row representing a single account. + */ +@interface NCAccountRow : NCHoverView +/** @brief Index of the account this row represents in the UserModel. */ +@property (nonatomic, assign) int userIndex; +/** @brief Delegate notified when the row is clicked or hovered. */ +@property (nonatomic, assign) id popupDelegate; +/** @brief Keeps the row highlighted even when not hovered (e.g. while its submenu is open). */ +- (void)setPersistentHighlight:(BOOL)persistentHighlight; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncaccountrow.mm b/src/gui/macOS/trayaccountpopup/ncaccountrow.mm new file mode 100644 index 0000000000000..914d1ad3d4a90 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncaccountrow.mm @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncaccountrow.h" + +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +using namespace OCC::Mac::TrayPopupViewUtils; + +@implementation NCAccountRow { + NSView *_hoverView; + BOOL _mouseInside; + BOOL _persistentHighlight; +} + +- (instancetype)init +{ + self = [super init]; + if (!self) return nil; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + + _hoverView = [[[NSView alloc] init] autorelease]; + _hoverView.wantsLayer = YES; + _hoverView.layer.backgroundColor = hoverColor().CGColor; + _hoverView.layer.cornerRadius = kHoverRadius; + _hoverView.hidden = YES; + [self addSubview:_hoverView]; + return self; +} + +- (void)updateHoverHighlight +{ + _hoverView.hidden = !(_mouseInside || _persistentHighlight); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + +- (void)layout +{ + [super layout]; + _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, kAccountHoverVerticalMargin); +} + +- (void)mouseEntered:(NSEvent *)event +{ + _mouseInside = YES; + [self updateHoverHighlight]; + [self.popupDelegate onAccountRowHovered:self]; +} + +- (void)mouseExited:(NSEvent *)event +{ + _mouseInside = NO; + [self updateHoverHighlight]; +} + +- (void)mouseUp:(NSEvent *)event +{ + [self.popupDelegate onAccountRowClicked:self.userIndex]; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncactionrow.h b/src/gui/macOS/trayaccountpopup/ncactionrow.h new file mode 100644 index 0000000000000..55bf7438c4a8a --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncactionrow.h @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import "nchoverview.h" +#import "trayaccountpopupmetrics.h" + +/** + * @brief A selectable menu row with a title and optional icon, subtitle, + * timestamp and submenu chevron. + * + * Runs its action block when clicked and its hover block when the pointer + * enters it, provided the row is enabled. A disabled row is dimmed and ignores + * clicks and hovers. + */ +@interface NCActionRow : NCHoverView +// The initializers below are convenience overloads; each forwards to the +// designated initializer (the one taking subtitle, dateTime and +// showsSubmenuIndicator) with the omitted arguments defaulted. +- (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 + dateTime:(NSString *)dateTime + width:(CGFloat)width + enabled:(BOOL)enabled + action:(dispatch_block_t)action + hoverAction:(NCActionHoverBlock)hoverAction + showsSubmenuIndicator:(BOOL)showsSubmenuIndicator; +/** + * @brief Designated initializer. + * @param title The row's primary text. + * @param icon Optional leading icon; pass nil for none. + * @param subtitle Optional secondary line (shown only on preview rows, i.e. when @c dateTime is set); pass nil to omit. + * @param dateTime Optional timestamp line; when set the row uses the taller preview layout. Pass nil to omit. + * @param width Fixed width of the row. + * @param enabled When NO, the row is dimmed and ignores clicks and hovers. + * @param action Block run when the row is clicked. + * @param hoverAction Block run when the pointer enters the row, receiving the row itself. + * @param showsSubmenuIndicator When YES, a trailing chevron marks that the row opens a submenu. + */ +- (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 + 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; +/** @brief Keeps an enabled row highlighted even when not hovered (e.g. while its submenu is open). */ +- (void)setPersistentHighlight:(BOOL)persistentHighlight; +/** @brief Replaces the leading icon (only if the row was created with one), applying the current label-tint setting. */ +- (void)setIcon:(NSImage *)icon; +/** @brief When YES, renders the icon as a template image tinted to the label colour. */ +- (void)setIconTintedToLabelColor:(BOOL)tinted; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncactionrow.mm b/src/gui/macOS/trayaccountpopup/ncactionrow.mm new file mode 100644 index 0000000000000..aaea2ce6f5588 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncactionrow.mm @@ -0,0 +1,388 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncactionrow.h" + +#import "trayaccountpopupviewutils.h" + +using namespace OCC::Mac::TrayPopupViewUtils; + +@implementation NCActionRow { + dispatch_block_t _action; + NCActionHoverBlock _hoverAction; + NSView *_hoverView; + NSImageView *_iconView; + NSTextField *_label; + BOOL _actionEnabled; + BOOL _iconTintedToLabelColor; + 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 +{ + 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; + _action = [action copy]; + _hoverAction = [hoverAction copy]; + _actionEnabled = enabled; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + + _hoverView = [[[NSView alloc] init] autorelease]; + _hoverView.wantsLayer = YES; + _hoverView.layer.backgroundColor = hoverColor().CGColor; + _hoverView.layer.cornerRadius = kHoverRadius; + _hoverView.hidden = YES; + [self addSubview:_hoverView]; + + _label = [NSTextField labelWithString:title]; + 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; + + NSView *textContainer = nil; + NSTextField *subtitleLabel = nil; + NSTextField *dateTimeLabel = nil; + if (isPreviewRow) { + textContainer = [[[NSView alloc] init] autorelease]; + 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:@[ + [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] autorelease]; + 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], + ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; + } + } else { + if (isPreviewRow) { + [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } else { + [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; + } + } + + if (icon) { + _iconView = [[[NSImageView alloc] init] autorelease]; + _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:kActionIconSize], + [_iconView.heightAnchor constraintEqualToConstant:kActionIconSize], + ]]; + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; + } + } else { + if (isPreviewRow) { + [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } else { + [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } + } + + auto availableTextWidth = width - (kHPad * 2.0); + if (icon) { + availableTextWidth -= kActionIconSize + 8.0; + } + if (showsSubmenuIndicator) { + availableTextWidth -= 8.0 + 8.0; + } + + auto needsToolTip = labelLikelyClipsText(_label, title, availableTextWidth); + if (subtitleLabel) { + needsToolTip = needsToolTip || labelLikelyClipsText(subtitleLabel, subtitle, availableTextWidth); + } + if (dateTimeLabel) { + needsToolTip = needsToolTip || labelLikelyClipsText(dateTimeLabel, dateTime, availableTextWidth); + } + if (needsToolTip) { + auto toolTipViews = [NSMutableArray arrayWithObjects:self, _label, nil]; + if (textContainer) { + [toolTipViews addObject:textContainer]; + } + if (subtitleLabel) { + [toolTipViews addObject:subtitleLabel]; + } + if (dateTimeLabel) { + [toolTipViews addObject:dateTimeLabel]; + } + setSharedToolTip(menuRowToolTipText(title, subtitle, dateTime), toolTipViews); + } + + [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); +} + +- (void)setPersistentHighlight:(BOOL)persistentHighlight +{ + _persistentHighlight = persistentHighlight; + [self updateHoverHighlight]; +} + +- (void)layout +{ + [super layout]; + _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, 0.0); +} + +- (void)mouseEntered:(NSEvent *)event +{ + _mouseInside = YES; + [self updateHoverHighlight]; + if (_actionEnabled && _hoverAction) _hoverAction(self); +} + +- (void)mouseExited:(NSEvent *)event +{ + _mouseInside = NO; + [self updateHoverHighlight]; +} + +- (void)mouseUp:(NSEvent *)event +{ + if (_actionEnabled && _action) _action(); +} + +- (void)dealloc +{ + [_action release]; + [_hoverAction release]; + [super dealloc]; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncalertboxrow.h b/src/gui/macOS/trayaccountpopup/ncalertboxrow.h new file mode 100644 index 0000000000000..30f637664a995 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncalertboxrow.h @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +#import "trayaccountpopupmetrics.h" + +/** + * @brief A row showing an account alert message alongside a "Resolve" button. + */ +@interface NCAlertBoxRow : NSView +/** + * @brief Creates an alert row. + * @param title The alert message. + * @param action Block run when the row or its "Resolve" button is clicked. + * @param hoverAction Block run when the pointer enters the row, receiving the row itself. + */ +- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncalertboxrow.mm b/src/gui/macOS/trayaccountpopup/ncalertboxrow.mm new file mode 100644 index 0000000000000..c657590bc0ce1 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncalertboxrow.mm @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncalertboxrow.h" + +#import "ncpointinghandbutton.h" +#import "trayaccountpopupviewutils.h" + +#include + +using namespace OCC::Mac::TrayPopupViewUtils; + +@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 = QCoreApplication::translate("TrayAccountPopup", "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], + ]]; + + const auto availableTextWidth = kPopupWidth - (kHPad + kAvatarSize + 10.0) - kHPad - 76.0 - 8.0; + if (labelLikelyClipsText(label, title, availableTextWidth)) { + setSharedToolTip(title, @[self, label]); + } + + 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 diff --git a/src/gui/macOS/trayaccountpopup/ncappspopup.h b/src/gui/macOS/trayaccountpopup/ncappspopup.h new file mode 100644 index 0000000000000..ab367dd676942 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncappspopup.h @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +@class NCTrayPopup; + +/** + * @brief A borderless popup listing the apps available for an account. + * + * Selecting an app opens its URL and closes the whole tray popup. + */ +@interface NCAppsPopup : NSPanel +/** + * @brief Rebuilds the app list for the given account (user) index. + * @param owner The root tray popup, used to close all popups when an app is opened. + */ +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncappspopup.mm b/src/gui/macOS/trayaccountpopup/ncappspopup.mm new file mode 100644 index 0000000000000..17b26b2bb6d96 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncappspopup.mm @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncappspopup.h" + +#import "ncactionrow.h" +#import "ncspacerview.h" +#import "nctraypopup.h" +#import "trayaccountpopupimageutils.h" +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +#include "accountmanager.h" +#include "accountstate.h" +#include "iconjob.h" +#include "tray/trayaccountappsmodel.h" + +#include +#include +#include +#include +#include +#include + +using namespace OCC::Mac::TrayPopupImageUtils; +using namespace OCC::Mac::TrayPopupViewUtils; + +// Process-wide cache of remotely fetched app icons, keyed by remoteAppIconCacheKey(). +static QHash s_remoteAppIconCache; + +@implementation NCAppsPopup { + NSStackView *_stack; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kAppsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + _stack = configurePopupPanel(self); + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + clearStack(_stack); + + __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]]; + const auto appIconRequestedSize = pixelSizeForPointSize(kActionIconSize, backingScaleFactorForWindow(self)); + const auto appIconDevicePixelRatio = static_cast(appIconRequestedSize.width()) / kActionIconSize; + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]); + for (auto row = 0; row < appsModel->rowCount(); ++row) { + const auto appIndex = appsModel->index(row); + const auto appName = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::NameRole)).toString(); + const auto appUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::UrlRole)).toUrl(); + const auto appIconUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::IconUrlRole)).toUrl(); + const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl, appIconRequestedSize); + auto appIcon = nsImageFromQUrl(appIconUrl); + if (!appIcon && !appIconCacheKey.isEmpty() && s_remoteAppIconCache.contains(appIconCacheKey)) { + appIcon = nsImageFromQImage(s_remoteAppIconCache.value(appIconCacheKey)); + } + auto actionRow = [[NCActionRow alloc] initWithTitle:appName.toNSString() + icon:appIcon != nil ? appIcon : fallbackIcon + width:kAppsPopupWidth + enabled:YES + action:^{ + [weakOwner closeAllPopups]; + appsModel->openAppUrl(appUrl); + }]; + [actionRow setIconTintedToLabelColor:YES]; + [_stack addArrangedSubview:actionRow]; + + if (!appIcon && !appIconCacheKey.isEmpty()) { + auto retainedRow = [actionRow retain]; + auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); + QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow, appIconCacheKey, appIconRequestedSize, appIconDevicePixelRatio](const QByteArray &iconData) { + auto image = qImageFromImageData(iconData, appIconRequestedSize); + if (!image.isNull()) { + image.setDevicePixelRatio(appIconDevicePixelRatio); + s_remoteAppIconCache.insert(appIconCacheKey, image); + [retainedRow setIcon:nsImageFromQImage(image)]; + } + [retainedRow release]; + }); + QObject::connect(iconJob, &OCC::IconJob::error, iconJob, [retainedRow](auto) { + [retainedRow release]; + }); + } + [actionRow release]; + } + addOwnedArrangedSubview(_stack, [[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 diff --git a/src/gui/macOS/trayaccountpopup/nchoverview.h b/src/gui/macOS/trayaccountpopup/nchoverview.h new file mode 100644 index 0000000000000..342edff856010 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/nchoverview.h @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** + * @brief Base view that highlights itself while the pointer is over it. + * + * Installs a mouse enter/exit tracking area covering its bounds and, by + * default, tints its layer background while hovered. Subclasses inherit the + * tracking-area setup and may override the enter/exit handlers to draw their + * own highlight instead. + */ +@interface NCHoverView : NSView +@end diff --git a/src/gui/macOS/trayaccountpopup/nchoverview.mm b/src/gui/macOS/trayaccountpopup/nchoverview.mm new file mode 100644 index 0000000000000..889d677188bb0 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/nchoverview.mm @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "nchoverview.h" + +@implementation NCHoverView + +- (instancetype)init +{ + self = [super init]; + if (!self) return nil; + self.wantsLayer = YES; + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + self.translatesAutoresizingMaskIntoConstraints = NO; + return self; +} + +- (void)mouseEntered:(NSEvent *)event +{ + self.layer.backgroundColor = [NSColor.labelColor colorWithAlphaComponent:0.08].CGColor; +} + +- (void)mouseExited:(NSEvent *)event +{ + self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); +} + +- (void)updateTrackingAreas +{ + [super updateTrackingAreas]; + auto trackingAreas = [self.trackingAreas copy]; + for (NSTrackingArea *ta in trackingAreas) { + [self removeTrackingArea:ta]; + } + [trackingAreas release]; + + auto trackingArea = [[NSTrackingArea alloc] + initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea]; + [trackingArea release]; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.h b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.h new file mode 100644 index 0000000000000..501be5cc2e677 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.h @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +#include + +@class NCTrayPopup; + +/** + * @brief A borderless popup listing the actions available for a single notification. + */ +@interface NCNotificationActionsPopup : NSPanel +/** + * @brief Rebuilds the popup for a notification's actions. + * @param activityIndex Index of the notification's activity in the UserModel. + * @param actions The notification's actions as provided by the UserModel. + * @param owner The root tray popup, used to open the Activities window or close all popups. + */ +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm new file mode 100644 index 0000000000000..1dc9091b995c5 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncnotificationactionspopup.h" + +#import "ncactionrow.h" +#import "ncspacerview.h" +#import "nctraypopup.h" +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +#include "tray/usermodel.h" + +#include + +using namespace OCC::Mac::TrayPopupViewUtils; + +@implementation NCNotificationActionsPopup { + NSStackView *_stack; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kNotificationActionsPopupWidth, 1) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + _stack = configurePopupPanel(self); + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner +{ + clearStack(_stack); + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCNotificationActionsPopup *weakSelf = self; + addOwnedArrangedSubview(_stack, [[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; + } + + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:title.toNSString() + width:kNotificationActionsPopupWidth + enabled:YES + action:^{ + if (dismisses) { + OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); + [weakSelf orderOut:nil]; + return; + } + if (opensActivities) { + [weakOwner openActivitiesForIndex:userIndex]; + return; + } + + [weakOwner closeAllPopups]; + OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); + }]); + } + addOwnedArrangedSubview(_stack, [[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 diff --git a/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.h b/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.h new file mode 100644 index 0000000000000..aa099920977aa --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.h @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** + * @brief An NSButton that shows the pointing-hand cursor while the pointer is over it. + */ +@interface NCPointingHandButton : NSButton +@end diff --git a/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.mm b/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.mm new file mode 100644 index 0000000000000..0d58dd8052e5e --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncpointinghandbutton.mm @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncpointinghandbutton.h" + +@implementation NCPointingHandButton + +- (void)resetCursorRects +{ + [super resetCursorRects]; + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.h b/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.h new file mode 100644 index 0000000000000..67d93e39f106c --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.h @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** + * @brief A non-interactive section header showing a small, secondary-colour title. + */ +@interface NCSectionHeaderRow : NSView +/** @brief Creates a header row with the given title and fixed @c width. */ +- (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.mm b/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.mm new file mode 100644 index 0000000000000..2f6ce03d5fb40 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncsectionheaderrow.mm @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncsectionheaderrow.h" + +#import "trayaccountpopupmetrics.h" + +@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 diff --git a/src/gui/macOS/trayaccountpopup/ncspacerview.h b/src/gui/macOS/trayaccountpopup/ncspacerview.h new file mode 100644 index 0000000000000..46a21eca6c181 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncspacerview.h @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** + * @brief A fixed-size empty view used to add spacing between rows. + */ +@interface NCSpacerView : NSView +/** @brief Creates a spacer of the given @c height and the default popup width. */ +- (instancetype)initWithHeight:(CGFloat)height; +/** @brief Creates a spacer of the given @c height and @c width. */ +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncspacerview.mm b/src/gui/macOS/trayaccountpopup/ncspacerview.mm new file mode 100644 index 0000000000000..f30b1e6e50692 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncspacerview.mm @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncspacerview.h" + +#import "trayaccountpopupmetrics.h" + +@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:width], + ]]; + return self; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/ncstaticinforow.h b/src/gui/macOS/trayaccountpopup/ncstaticinforow.h new file mode 100644 index 0000000000000..c5fa61bb7f5e5 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncstaticinforow.h @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** + * @brief A non-interactive informational row with a title and optional leading icon. + * + * Used for placeholders such as a "No recent activity" entry. + */ +@interface NCStaticInfoRow : NSView +/** + * @brief Creates an informational row. + * @param icon Optional leading icon; pass nil for none. + * @param width Fixed width of the row. + */ +- (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width; +@end diff --git a/src/gui/macOS/trayaccountpopup/ncstaticinforow.mm b/src/gui/macOS/trayaccountpopup/ncstaticinforow.mm new file mode 100644 index 0000000000000..f39ecfeda0b62 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/ncstaticinforow.mm @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncstaticinforow.h" + +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +using namespace OCC::Mac::TrayPopupViewUtils; + +@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] autorelease]; + 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]]; + } + + auto availableTextWidth = width - (kHPad * 2.0); + if (icon) { + availableTextWidth -= kActivityPreviewIconSize + 8.0; + } + if (labelLikelyClipsText(label, title, availableTextWidth)) { + setSharedToolTip(title, @[self, label]); + } + + [NSLayoutConstraint activateConstraints:constraints]; + return self; +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/nctraypopup.h b/src/gui/macOS/trayaccountpopup/nctraypopup.h new file mode 100644 index 0000000000000..ec85f79dde008 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/nctraypopup.h @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +#import "ncaccountrow.h" + +/** + * @brief The root tray popup. + * + * A borderless panel listing the configured accounts plus the global + * "Add account", "Settings" and "Quit" actions. Acts as the delegate for its + * account rows and owns the per-account actions submenu. + */ +@interface NCTrayPopup : NSPanel +/** @brief Rebuilds the account list and global action rows and resizes the panel to fit. */ +- (void)populate; +/** @brief Closes this popup and the account-actions submenu and marks the systray closed. */ +- (void)closeAllPopups; +/** @brief Closes only the account-actions submenu and clears the active account row. */ +- (void)closeAccountActionsPopup; +/** @brief Removes the persistent highlight from the currently active account row. */ +- (void)clearActiveAccountRow; +/** @brief Closes the popups and opens the Activities window for the given account. */ +- (void)openActivitiesForIndex:(int)index; +/** @brief Closes the popups and reveals the given account's local folder (or file provider domain) in Finder. */ +- (void)openLocalFolderForIndex:(int)index; +/** @brief Closes the popups and opens the Assistant window for the given account. */ +- (void)openAssistantForIndex:(int)index; +/** @brief Closes the popups and opens the user-status window for the given account. */ +- (void)openOnlineStatusForIndex:(int)index; +@end diff --git a/src/gui/macOS/trayaccountpopup/nctraypopup.mm b/src/gui/macOS/trayaccountpopup/nctraypopup.mm new file mode 100644 index 0000000000000..5594f4dccfcfd --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/nctraypopup.mm @@ -0,0 +1,314 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "nctraypopup.h" + +#import "ncaccountactionspopup.h" +#import "ncactionrow.h" +#import "ncalertboxrow.h" +#import "ncspacerview.h" +#import "trayaccountpopupimageutils.h" +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +#include "systray.h" +#include "tray/usermodel.h" + +#include +#include + +using namespace OCC::Mac::TrayPopupImageUtils; +using namespace OCC::Mac::TrayPopupViewUtils; + +@implementation NCTrayPopup { + NSStackView *_stack; + NCAccountActionsPopup *_accountActionsPopup; + NCAccountRow *_activeAccountRow; +} + +- (instancetype)init +{ + self = [super initWithContentRect:NSMakeRect(0, 0, kPopupWidth, 120) + styleMask:NSWindowStyleMaskBorderless + backing:NSBackingStoreBuffered + defer:NO]; + if (!self) return nil; + + _stack = configurePopupPanel(self); + return self; +} + +- (BOOL)canBecomeKeyWindow { return YES; } + +- (void)dealloc +{ + [_accountActionsPopup release]; + [super dealloc]; +} + +- (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 + avatar:(NSImage *)avatar + syncStatusImage:(NSImage *)syncStatusImage +{ + NSImageView *avatarView = [[[NSImageView alloc] init] autorelease]; + avatarView.image = avatar != nil ? avatar : [NSImage imageWithSystemSymbolName:@"person.circle.fill" + accessibilityDescription:nil]; + avatarView.wantsLayer = YES; + avatarView.layer.cornerRadius = kAvatarSize / 2.0; + avatarView.layer.masksToBounds = YES; + avatarView.imageScaling = NSImageScaleProportionallyUpOrDown; + avatarView.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *nameLabel = [NSTextField labelWithString:name]; + nameLabel.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + nameLabel.textColor = NSColor.labelColor; + nameLabel.lineBreakMode = NSLineBreakByTruncatingTail; + nameLabel.translatesAutoresizingMaskIntoConstraints = NO; + + NSTextField *serverLabel = [NSTextField labelWithString:server]; + serverLabel.font = [NSFont systemFontOfSize:11]; + serverLabel.textColor = NSColor.secondaryLabelColor; + serverLabel.lineBreakMode = NSLineBreakByTruncatingTail; + serverLabel.translatesAutoresizingMaskIntoConstraints = NO; + + NCAccountRow *row = [[[NCAccountRow alloc] init] autorelease]; + row.userIndex = index; + row.popupDelegate = self; + + NSImageView *statusView = [[[NSImageView alloc] init] autorelease]; + statusView.image = syncStatusImage; + statusView.translatesAutoresizingMaskIntoConstraints = NO; + + NSImageView *chevron = [[[NSImageView alloc] init] autorelease]; + chevron.image = [[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:nil] + imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:11 weight:NSFontWeightMedium]]; + chevron.contentTintColor = NSColor.tertiaryLabelColor; + chevron.translatesAutoresizingMaskIntoConstraints = NO; + + [row addSubview:avatarView]; + [row addSubview:nameLabel]; + [row addSubview:serverLabel]; + [row addSubview:statusView]; + [row addSubview:chevron]; + + [NSLayoutConstraint activateConstraints:@[ + [avatarView.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:kHPad], + [avatarView.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], + [avatarView.widthAnchor constraintEqualToConstant:kAvatarSize], + [avatarView.heightAnchor constraintEqualToConstant:kAvatarSize], + + [nameLabel.leadingAnchor constraintEqualToAnchor:avatarView.trailingAnchor constant:10], + [nameLabel.topAnchor constraintEqualToAnchor:row.topAnchor constant:9], + [nameLabel.trailingAnchor constraintLessThanOrEqualToAnchor:statusView.leadingAnchor constant:-8], + + [serverLabel.leadingAnchor constraintEqualToAnchor:nameLabel.leadingAnchor], + [serverLabel.topAnchor constraintEqualToAnchor:nameLabel.bottomAnchor constant:2], + [serverLabel.trailingAnchor constraintLessThanOrEqualToAnchor:statusView.leadingAnchor constant:-8], + + [chevron.trailingAnchor constraintEqualToAnchor:row.trailingAnchor constant:-kHPad], + [chevron.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], + [chevron.widthAnchor constraintEqualToConstant:8], + [chevron.heightAnchor constraintEqualToConstant:13], + + [statusView.trailingAnchor constraintEqualToAnchor:chevron.leadingAnchor constant:-8], + [statusView.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], + [statusView.widthAnchor constraintEqualToConstant:18], + [statusView.heightAnchor constraintEqualToConstant:18], + + [row.heightAnchor constraintEqualToConstant:kRowHeight], + [row.widthAnchor constraintEqualToConstant:kPopupWidth], + ]]; + + const auto labelAvailableWidth = kPopupWidth - kHPad - kAvatarSize - 10.0 - 8.0 - 18.0 - 8.0 - 8.0 - kHPad; + if (labelLikelyClipsText(nameLabel, name, labelAvailableWidth) || labelLikelyClipsText(serverLabel, server, labelAvailableWidth)) { + setSharedToolTip(menuRowToolTipText(name, server, nil), @[row, nameLabel, serverLabel]); + } + + return row; +} + +- (void)populate +{ + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + + clearStack(_stack); + + OCC::UserModel *model = OCC::UserModel::instance(); + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kTopPadding]); + for (int i = 0; i < model->rowCount(); ++i) { + const QModelIndex idx = model->index(i); + NSString *name = model->data(idx, OCC::UserModel::NameRole).toString().toNSString(); + 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()) { + addOwnedArrangedSubview(_stack, [[NCAlertBoxRow alloc] initWithTitle:accountAlertTitle.toNSString() + action:^{ + [self openActivitiesForIndex:i]; + } hoverAction:^(NSView *) { + [self closeAccountActionsPopup]; + }]); + } + } + + if (model->rowCount() > 0) { + NSBox *sep = [[NSBox alloc] init]; + sep.boxType = NSBoxSeparator; + sep.translatesAutoresizingMaskIntoConstraints = NO; + [_stack addArrangedSubview:sep]; + [sep.widthAnchor constraintEqualToConstant:kPopupWidth].active = YES; + [sep release]; + } + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); + + __unsafe_unretained NCTrayPopup *weakSelf = self; + if (OCC::Systray::instance()->enableAddAccount()) { + addOwnedArrangedSubview(_stack, [[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]; + }]); + } + addOwnedArrangedSubview(_stack, [[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]; + }]); + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() + width:kPopupWidth + enabled:YES + action:^{ + OCC::Systray::instance()->shutdown(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; + }]); + addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.height = _stack.fittingSize.height; + [self setFrame:frame display:NO]; + [self invalidateShadow]; +} + +- (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]; + + positionPopupFromRow(_accountActionsPopup, row); + [_accountActionsPopup orderFront:nil]; +} + +- (void)openActivitiesForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + OCC::Systray::instance()->showActivitiesWindow(index); +} + +- (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)openAssistantForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->showAssistantWindow(index); +} + +- (void)openOnlineStatusForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + OCC::Systray::instance()->showUserStatusWindow(index); +} + +@end diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup/trayaccountpopup_mac.mm new file mode 100644 index 0000000000000..3256a8e698d87 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopup_mac.mm @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "nctraypopup.h" + +#import "trayaccountpopupmetrics.h" +#import "trayaccountpopupviewutils.h" + +#include "systray.h" + +#include +#include +#include + +using namespace OCC::Mac::TrayPopupViewUtils; + +static NSScreen *nsScreenForQtScreen(QScreen *qtScreen) +{ + if (!qtScreen) { + return NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + + const auto qtScreenName = qtScreen->name().toNSString(); + for (NSScreen *candidate in NSScreen.screens) { + if ([candidate.localizedName isEqualToString:qtScreenName]) { + return candidate; + } + } + + const auto qtScreens = QGuiApplication::screens(); + const auto screenIndex = qtScreens.indexOf(qtScreen); + if (screenIndex >= 0 && screenIndex < static_cast(NSScreen.screens.count)) { + return [NSScreen.screens objectAtIndex:screenIndex]; + } + + return NSScreen.mainScreen ?: NSScreen.screens.firstObject; +} + +namespace OCC { + +static NCTrayPopup *s_popup = nil; + +void showMacOSTrayPopup(const QRect &iconRect) +{ + if (!s_popup) { + s_popup = [[NCTrayPopup alloc] init]; + } + + [s_popup populate]; + + auto qtScreen = iconRect.isValid() && !iconRect.isNull() + ? QGuiApplication::screenAt(iconRect.center()) + : nullptr; + NSScreen *screen = nsScreenForQtScreen(qtScreen); + if (!screen) { + screen = NSScreen.screens.firstObject; + } + + const CGFloat popupW = s_popup.frame.size.width; + const CGFloat popupH = s_popup.frame.size.height; + const NSRect visibleFrame = screen.visibleFrame; + + CGFloat x, y; + if (iconRect.isValid() && !iconRect.isNull() && qtScreen) { + const auto qtScreenGeometry = qtScreen->geometry(); + x = NSMinX(screen.frame) + iconRect.x() - qtScreenGeometry.x() - kStatusItemLeadingOffset; + y = NSMaxY(screen.frame) - (iconRect.y() + iconRect.height() - qtScreenGeometry.y()) - popupH; + } else { + x = NSMaxX(visibleFrame) - popupW - kScreenEdgePadding; + y = NSMaxY(visibleFrame) - popupH; + } + + const auto popupOrigin = clampedPopupOrigin(NSMakePoint(x, y), NSMakeSize(popupW, popupH), visibleFrame); + + [s_popup setFrameOrigin:popupOrigin]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + [NSApp activateIgnoringOtherApps:YES]; +#pragma clang diagnostic pop + [s_popup makeKeyAndOrderFront:nil]; +} + +void hideMacOSTrayPopup() +{ + [s_popup orderOut:nil]; +} + +} // namespace OCC diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.h b/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.h new file mode 100644 index 0000000000000..d8e38021bffb0 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.h @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountfwd.h" + +#include +#include +#include +#include +#include + +#import + +namespace OCC::Mac::TrayPopupImageUtils { + +// Decode raw image data (raster or SVG) into a QImage scaled into requestedSize. +QImage qImageFromImageData(const QByteArray &imageData, const QSize &requestedSize); + +// Bridge a QImage into an autoreleased NSImage, honouring the device pixel ratio. +NSImage *nsImageFromQImage(const QImage &qimg); + +// Load a QImage from a local-file or qrc URL. Any other URL is handed to QImage +// as a plain file path, so it is not fetched over the network. +QImage qImageFromQUrl(const QUrl &url); + +// Convenience: load a URL straight into an autoreleased NSImage. +NSImage *nsImageFromQUrl(const QUrl &url); + +// Resolve an SF Symbol by name at the given point size, falling back to "doc". +NSImage *systemSymbolImage(const QString &symbolName, const CGFloat pointSize); + +// Build the cache key used to memoise remotely fetched app icons, or an empty +// string when the inputs cannot form a valid key. +QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url, const QSize &requestedSize); + +// Backing scale factor of the screen the window is on, falling back to the main +// screen and finally 1.0. +qreal backingScaleFactorForWindow(NSWindow *window); + +// Convert a point size to an integer pixel size for the given scale (>= 1px). +QSize pixelSizeForPointSize(const CGFloat pointSize, const qreal scale); + +} diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.mm b/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.mm new file mode 100644 index 0000000000000..713d46cbc4545 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopupimageutils.mm @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "trayaccountpopupimageutils.h" + +#include "account.h" +#include "accountstate.h" + +#include +#include +#include +#include +#include + +namespace OCC::Mac::TrayPopupImageUtils { + +NSImage *nsImageFromQImage(const QImage &qimg) +{ + if (qimg.isNull()) return nil; + const auto devicePixelRatio = qimg.devicePixelRatio() > 0.0 ? qimg.devicePixelRatio() : 1.0; + const QImage rgba = qimg.convertToFormat(QImage::Format_RGBA8888); + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:nullptr + pixelsWide:rgba.width() + pixelsHigh:rgba.height() + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSCalibratedRGBColorSpace + bytesPerRow:rgba.bytesPerLine() + bitsPerPixel:32]; + if (!rep || !rep.bitmapData) { + [rep release]; + return nil; + } + memcpy(rep.bitmapData, rgba.constBits(), (size_t)rgba.bytesPerLine() * rgba.height()); + const auto imageSize = NSMakeSize(rgba.width() / devicePixelRatio, rgba.height() / devicePixelRatio); + rep.size = imageSize; + NSImage *img = [[NSImage alloc] initWithSize:imageSize]; + [img addRepresentation:rep]; + [rep release]; + return [img autorelease]; +} + +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; +} + +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); +} + +NSImage *nsImageFromQUrl(const QUrl &url) +{ + return nsImageFromQImage(qImageFromQUrl(url)); +} + +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]]; +} + +QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url, const QSize &requestedSize) +{ + if (!accountState || !accountState->account() || !url.isValid() || url.scheme().isEmpty() || !requestedSize.isValid()) { + return {}; + } + + return QStringLiteral("%1:%2x%3:%4").arg( + accountState->account()->id(), + QString::number(requestedSize.width()), + QString::number(requestedSize.height()), + url.toString()); +} + +qreal backingScaleFactorForWindow(NSWindow *window) +{ + auto scale = window.screen.backingScaleFactor; + if (scale <= 0.0) { + scale = NSScreen.mainScreen.backingScaleFactor; + } + return scale > 0.0 ? scale : 1.0; +} + +QSize pixelSizeForPointSize(const CGFloat pointSize, const qreal scale) +{ + auto pixelSize = static_cast(pointSize * scale + 0.5); + if (pixelSize < 1) { + pixelSize = 1; + } + return QSize(pixelSize, pixelSize); +} + +} diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopupmetrics.h b/src/gui/macOS/trayaccountpopup/trayaccountpopupmetrics.h new file mode 100644 index 0000000000000..de96e9198e38c --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopupmetrics.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +// Shared, lightweight declarations for the macOS tray account popup. +// +// These are intentionally kept together in a single header rather than split +// per identifier: they are plain layout constants and a single block alias, not +// stand-alone types. Every value below has internal linkage (a `constexpr` at +// namespace scope is implicitly `const`), so including this header in multiple +// translation units is free of ODR conflicts. + +// Layout metrics shared across the popup views. Keep behavior and menu taxonomy +// aligned with src/gui/trayaccountpopup_qt.cpp. +constexpr CGFloat kPopupWidth = 300.0; +constexpr CGFloat kRowHeight = 48.0; +constexpr CGFloat kAvatarSize = 34.0; +constexpr CGFloat kTopPadding = 4.0; +constexpr CGFloat kActionHeight = 26.0; +constexpr CGFloat kActionIconSize = 18.0; +constexpr CGFloat kPreviewActionHeight = 52.0; +constexpr CGFloat kDetailedPreviewActionHeight = 58.0; +constexpr CGFloat kActionVerticalPadding = 8.0; +constexpr CGFloat kCornerRadius = 14.0; +constexpr CGFloat kHPad = 14.0; +constexpr CGFloat kScreenEdgePadding = 8.0; +constexpr CGFloat kStatusItemLeadingOffset = 3.0; +constexpr CGFloat kHoverMargin = 5.0; +constexpr CGFloat kHoverRadius = 5.0; +constexpr CGFloat kAccountHoverVerticalMargin = 4.0; +constexpr CGFloat kCompactSeparatorVerticalMargin = 2.0; +constexpr CGFloat kAccountActionsPopupWidth = 340.0; +constexpr CGFloat kAppsPopupWidth = 220.0; +constexpr CGFloat kNotificationActionsPopupWidth = 160.0; +constexpr CGFloat kSectionHeaderHeight = 24.0; +constexpr CGFloat kActivityPreviewIconSize = 16.0; + +// Block invoked when the pointer enters an interactive row, passing the hovered +// row so the owning popup can react (e.g. reveal or hide a submenu). +typedef void (^NCActionHoverBlock)(NSView *row); diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.h b/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.h new file mode 100644 index 0000000000000..978bcdec50196 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.h @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +namespace OCC::Mac::TrayPopupViewUtils { + +// Build the blurred, rounded panel chrome and return the vertical stack view the +// caller fills with rows. +NSStackView *configurePopupPanel(NSPanel *panel); + +// Remove and release every arranged subview from a stack. +void clearStack(NSStackView *stack); + +// Add a freshly-allocated view to the stack and release the caller's +1 reference. +void addOwnedArrangedSubview(NSStackView *stack, NSView *view); + +// The subtle row highlight colour for the current (light/dark) appearance. +NSColor *hoverColor(); + +// Clamp a popup origin toward the screen's visible frame, inset by kScreenEdgePadding. +// A popup larger than the visible frame can still overflow its far edge. +NSPoint clampedPopupOrigin(const NSPoint origin, const NSSize size, const NSRect visibleFrame); + +// Position a submenu popup next to the given row, flipping and clamping to screen. +void positionPopupFromRow(NSPanel *popup, NSView *row); + +// Heuristic: would the label's text be clipped in availableWidth given its font +// and maximum line count? +BOOL labelLikelyClipsText(NSTextField *label, NSString *text, const CGFloat availableWidth); + +// Join the non-empty title/subtitle/date-time into a multi-line tool tip string. +NSString *menuRowToolTipText(NSString *title, NSString *subtitle, NSString *dateTime); + +// Apply the same tool tip to every view in the array (no-op for an empty tip). +void setSharedToolTip(NSString *toolTip, NSArray *views); + +} diff --git a/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.mm b/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.mm new file mode 100644 index 0000000000000..dba2f1fcfc509 --- /dev/null +++ b/src/gui/macOS/trayaccountpopup/trayaccountpopupviewutils.mm @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "trayaccountpopupviewutils.h" + +#include "trayaccountpopupmetrics.h" + +namespace OCC::Mac::TrayPopupViewUtils { + +static CGFloat clampedPopupOriginCoordinate(const CGFloat origin, const CGFloat minEdge, const CGFloat maxEdge, const CGFloat size) +{ + const auto minOrigin = minEdge + kScreenEdgePadding; + const auto maxOrigin = maxEdge - size - kScreenEdgePadding; + if (maxOrigin < minOrigin) { + return minOrigin; + } + return origin < minOrigin ? minOrigin : (origin > maxOrigin ? maxOrigin : origin); +} + +NSPoint clampedPopupOrigin(const NSPoint origin, const NSSize size, const NSRect visibleFrame) +{ + return NSMakePoint(clampedPopupOriginCoordinate(origin.x, NSMinX(visibleFrame), NSMaxX(visibleFrame), size.width), + clampedPopupOriginCoordinate(origin.y, NSMinY(visibleFrame), NSMaxY(visibleFrame), size.height)); +} + +void addOwnedArrangedSubview(NSStackView *stack, NSView *view) +{ + [stack addArrangedSubview:view]; + [view release]; +} + +static CGFloat textWidth(NSString *text, NSFont *font) +{ + if (text.length == 0 || !font) { + return 0.0; + } + + return [text sizeWithAttributes:@{ NSFontAttributeName : font }].width; +} + +BOOL labelLikelyClipsText(NSTextField *label, NSString *text, const CGFloat availableWidth) +{ + if (!label || text.length == 0 || availableWidth <= 0.0) { + return NO; + } + + const auto maximumNumberOfLines = label.maximumNumberOfLines > 0 ? label.maximumNumberOfLines : 1; + return textWidth(text, label.font) > availableWidth * maximumNumberOfLines; +} + +NSString *menuRowToolTipText(NSString *title, NSString *subtitle, NSString *dateTime) +{ + auto parts = [NSMutableArray array]; + if (title.length > 0) { + [parts addObject:title]; + } + if (subtitle.length > 0) { + [parts addObject:subtitle]; + } + if (dateTime.length > 0) { + [parts addObject:dateTime]; + } + + return parts.count > 0 ? [parts componentsJoinedByString:@"\n"] : nil; +} + +void setSharedToolTip(NSString *toolTip, NSArray *views) +{ + if (toolTip.length == 0) { + return; + } + + for (NSView *view in views) { + view.toolTip = toolTip; + } +} + +NSStackView *configurePopupPanel(NSPanel *panel) +{ + panel.level = NSPopUpMenuWindowLevel; + panel.hasShadow = YES; + panel.releasedWhenClosed = NO; + panel.backgroundColor = NSColor.clearColor; + panel.opaque = NO; + + auto container = [[[NSView alloc] init] autorelease]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + panel.contentView = container; + + auto visualEffectView = [[[NSVisualEffectView alloc] init] autorelease]; + visualEffectView.material = NSVisualEffectMaterialHUDWindow; + visualEffectView.blendingMode = NSVisualEffectBlendingModeBehindWindow; + visualEffectView.state = NSVisualEffectStateActive; + visualEffectView.wantsLayer = YES; + visualEffectView.layer.cornerRadius = kCornerRadius; + visualEffectView.layer.masksToBounds = YES; + visualEffectView.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:visualEffectView]; + + auto stack = [NSStackView stackViewWithViews:@[]]; + stack.orientation = NSUserInterfaceLayoutOrientationVertical; + stack.spacing = 0; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [visualEffectView addSubview:stack]; + + [NSLayoutConstraint activateConstraints:@[ + [visualEffectView.topAnchor constraintEqualToAnchor:container.topAnchor], + [visualEffectView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [visualEffectView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [visualEffectView.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + [stack.topAnchor constraintEqualToAnchor:visualEffectView.topAnchor], + [stack.leadingAnchor constraintEqualToAnchor:visualEffectView.leadingAnchor], + [stack.trailingAnchor constraintEqualToAnchor:visualEffectView.trailingAnchor], + [stack.bottomAnchor constraintEqualToAnchor:visualEffectView.bottomAnchor], + ]]; + + return stack; +} + +void clearStack(NSStackView *stack) +{ + auto arrangedSubviews = [stack.arrangedSubviews copy]; + for (NSView *view in arrangedSubviews) { + [stack removeArrangedSubview:view]; + [view removeFromSuperview]; + } + [arrangedSubviews release]; +} + +void positionPopupFromRow(NSPanel *popup, NSView *row) +{ + 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 = popup.frame.size.width; + const auto popupHeight = popup.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; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin = clampedPopupOrigin(popupOrigin, NSMakeSize(popupWidth, popupHeight), visibleFrame); + + [popup setFrameOrigin:popupOrigin]; +} + +NSColor *hoverColor() +{ + NSAppearanceName appearanceName = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:@[ + NSAppearanceNameAqua, + NSAppearanceNameDarkAqua, + ]]; + const BOOL isDarkMode = [appearanceName isEqualToString:NSAppearanceNameDarkAqua]; + return [(isDarkMode ? NSColor.whiteColor : NSColor.blackColor) colorWithAlphaComponent:0.08]; +} + +} diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm deleted file mode 100644 index d4e092069046b..0000000000000 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ /dev/null @@ -1,2031 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include "systray.h" -#include "accountmanager.h" -#include "iconjob.h" -#include "tray/trayaccountappsmodel.h" -#include "tray/usermodel.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#import - -// Keep behavior and menu taxonomy aligned with src/gui/trayaccountpopup_qt.cpp. - -static const CGFloat kPopupWidth = 300.0; -static const CGFloat kRowHeight = 48.0; -static const CGFloat kAvatarSize = 34.0; -static const CGFloat kTopPadding = 4.0; -static const CGFloat kActionHeight = 26.0; -static const CGFloat kActionIconSize = 18.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; -static const CGFloat kScreenEdgePadding = 8.0; -static const CGFloat kStatusItemLeadingOffset = 3.0; -static const CGFloat kHoverMargin = 5.0; -static const CGFloat kHoverRadius = 5.0; -static const CGFloat kAccountHoverVerticalMargin = 4.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); - -static QHash s_remoteAppIconCache; - -static CGFloat clampedPopupOriginCoordinate(const CGFloat origin, const CGFloat minEdge, const CGFloat maxEdge, const CGFloat size) -{ - const auto minOrigin = minEdge + kScreenEdgePadding; - const auto maxOrigin = maxEdge - size - kScreenEdgePadding; - if (maxOrigin < minOrigin) { - return minOrigin; - } - return origin < minOrigin ? minOrigin : (origin > maxOrigin ? maxOrigin : origin); -} - -static NSPoint clampedPopupOrigin(const NSPoint origin, const NSSize size, const NSRect visibleFrame) -{ - return NSMakePoint(clampedPopupOriginCoordinate(origin.x, NSMinX(visibleFrame), NSMaxX(visibleFrame), size.width), - clampedPopupOriginCoordinate(origin.y, NSMinY(visibleFrame), NSMaxY(visibleFrame), size.height)); -} - -static NSScreen *nsScreenForQtScreen(QScreen *qtScreen) -{ - if (!qtScreen) { - return NSScreen.mainScreen ?: NSScreen.screens.firstObject; - } - - const auto qtScreenName = qtScreen->name().toNSString(); - for (NSScreen *candidate in NSScreen.screens) { - if ([candidate.localizedName isEqualToString:qtScreenName]) { - return candidate; - } - } - - const auto qtScreens = QGuiApplication::screens(); - const auto screenIndex = qtScreens.indexOf(qtScreen); - if (screenIndex >= 0 && screenIndex < static_cast(NSScreen.screens.count)) { - return [NSScreen.screens objectAtIndex:screenIndex]; - } - - return NSScreen.mainScreen ?: NSScreen.screens.firstObject; -} - -static QString remoteAppIconCacheKey(const OCC::AccountStatePtr &accountState, const QUrl &url, const QSize &requestedSize) -{ - if (!accountState || !accountState->account() || !url.isValid() || url.scheme().isEmpty() || !requestedSize.isValid()) { - return {}; - } - - return QStringLiteral("%1:%2x%3:%4").arg( - accountState->account()->id(), - QString::number(requestedSize.width()), - QString::number(requestedSize.height()), - url.toString()); -} - -static qreal backingScaleFactorForWindow(NSWindow *window) -{ - auto scale = window.screen.backingScaleFactor; - if (scale <= 0.0) { - scale = NSScreen.mainScreen.backingScaleFactor; - } - return scale > 0.0 ? scale : 1.0; -} - -static QSize pixelSizeForPointSize(const CGFloat pointSize, const qreal scale) -{ - auto pixelSize = static_cast(pointSize * scale + 0.5); - if (pixelSize < 1) { - pixelSize = 1; - } - return QSize(pixelSize, pixelSize); -} - -static void addOwnedArrangedSubview(NSStackView *stack, NSView *view) -{ - [stack addArrangedSubview:view]; - [view release]; -} - -static CGFloat textWidth(NSString *text, NSFont *font) -{ - if (text.length == 0 || !font) { - return 0.0; - } - - return [text sizeWithAttributes:@{ NSFontAttributeName : font }].width; -} - -static BOOL labelLikelyClipsText(NSTextField *label, NSString *text, const CGFloat availableWidth) -{ - if (!label || text.length == 0 || availableWidth <= 0.0) { - return NO; - } - - const auto maximumNumberOfLines = label.maximumNumberOfLines > 0 ? label.maximumNumberOfLines : 1; - return textWidth(text, label.font) > availableWidth * maximumNumberOfLines; -} - -static NSString *menuRowToolTipText(NSString *title, NSString *subtitle, NSString *dateTime) -{ - auto parts = [NSMutableArray array]; - if (title.length > 0) { - [parts addObject:title]; - } - if (subtitle.length > 0) { - [parts addObject:subtitle]; - } - if (dateTime.length > 0) { - [parts addObject:dateTime]; - } - - return parts.count > 0 ? [parts componentsJoinedByString:@"\n"] : nil; -} - -static void setSharedToolTip(NSString *toolTip, NSArray *views) -{ - if (toolTip.length == 0) { - return; - } - - for (NSView *view in views) { - view.toolTip = toolTip; - } -} - -static NSStackView *configurePopupPanel(NSPanel *panel) -{ - panel.level = NSPopUpMenuWindowLevel; - panel.hasShadow = YES; - panel.releasedWhenClosed = NO; - panel.backgroundColor = NSColor.clearColor; - panel.opaque = NO; - - auto container = [[[NSView alloc] init] autorelease]; - container.wantsLayer = YES; - container.layer.cornerRadius = kCornerRadius; - container.layer.masksToBounds = YES; - panel.contentView = container; - - auto visualEffectView = [[[NSVisualEffectView alloc] init] autorelease]; - visualEffectView.material = NSVisualEffectMaterialHUDWindow; - visualEffectView.blendingMode = NSVisualEffectBlendingModeBehindWindow; - visualEffectView.state = NSVisualEffectStateActive; - visualEffectView.wantsLayer = YES; - visualEffectView.layer.cornerRadius = kCornerRadius; - visualEffectView.layer.masksToBounds = YES; - visualEffectView.translatesAutoresizingMaskIntoConstraints = NO; - [container addSubview:visualEffectView]; - - auto stack = [NSStackView stackViewWithViews:@[]]; - stack.orientation = NSUserInterfaceLayoutOrientationVertical; - stack.spacing = 0; - stack.translatesAutoresizingMaskIntoConstraints = NO; - [visualEffectView addSubview:stack]; - - [NSLayoutConstraint activateConstraints:@[ - [visualEffectView.topAnchor constraintEqualToAnchor:container.topAnchor], - [visualEffectView.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], - [visualEffectView.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], - [visualEffectView.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], - [stack.topAnchor constraintEqualToAnchor:visualEffectView.topAnchor], - [stack.leadingAnchor constraintEqualToAnchor:visualEffectView.leadingAnchor], - [stack.trailingAnchor constraintEqualToAnchor:visualEffectView.trailingAnchor], - [stack.bottomAnchor constraintEqualToAnchor:visualEffectView.bottomAnchor], - ]]; - - return stack; -} - -static void clearStack(NSStackView *stack) -{ - auto arrangedSubviews = [stack.arrangedSubviews copy]; - for (NSView *view in arrangedSubviews) { - [stack removeArrangedSubview:view]; - [view removeFromSuperview]; - } - [arrangedSubviews release]; -} - -static void positionPopupFromRow(NSPanel *popup, NSView *row) -{ - 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 = popup.frame.size.width; - const auto popupHeight = popup.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; - - if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { - popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; - } - popupOrigin = clampedPopupOrigin(popupOrigin, NSMakeSize(popupWidth, popupHeight), visibleFrame); - - [popup setFrameOrigin:popupOrigin]; -} - -static NSColor *hoverColor() -{ - NSAppearanceName appearanceName = [NSApp.effectiveAppearance bestMatchFromAppearancesWithNames:@[ - NSAppearanceNameAqua, - NSAppearanceNameDarkAqua, - ]]; - const BOOL isDarkMode = [appearanceName isEqualToString:NSAppearanceNameDarkAqua]; - return [(isDarkMode ? NSColor.whiteColor : NSColor.blackColor) colorWithAlphaComponent:0.08]; -} - -static NSImage *nsImageFromQImage(const QImage &qimg) -{ - if (qimg.isNull()) return nil; - const auto devicePixelRatio = qimg.devicePixelRatio() > 0.0 ? qimg.devicePixelRatio() : 1.0; - const QImage rgba = qimg.convertToFormat(QImage::Format_RGBA8888); - NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] - initWithBitmapDataPlanes:nullptr - pixelsWide:rgba.width() - pixelsHigh:rgba.height() - bitsPerSample:8 - samplesPerPixel:4 - hasAlpha:YES - isPlanar:NO - colorSpaceName:NSCalibratedRGBColorSpace - bytesPerRow:rgba.bytesPerLine() - bitsPerPixel:32]; - if (!rep || !rep.bitmapData) { - [rep release]; - return nil; - } - memcpy(rep.bitmapData, rgba.constBits(), (size_t)rgba.bytesPerLine() * rgba.height()); - const auto imageSize = NSMakeSize(rgba.width() / devicePixelRatio, rgba.height() / devicePixelRatio); - rep.size = imageSize; - NSImage *img = [[NSImage alloc] initWithSize:imageSize]; - [img addRepresentation:rep]; - [rep release]; - return [img autorelease]; -} - -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 {}; - - 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 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) { - 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 statusMenuText(OCC::UserStatus::OnlineStatus status, const QString &message) -{ - const auto trimmedMessage = message.trimmed(); - return trimmedMessage.isEmpty() ? statusText(status) : trimmedMessage; -} - -@interface NCHoverView : NSView -@end - -@implementation NCHoverView - -- (instancetype)init -{ - self = [super init]; - if (!self) return nil; - self.wantsLayer = YES; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - self.translatesAutoresizingMaskIntoConstraints = NO; - return self; -} - -- (void)mouseEntered:(NSEvent *)event -{ - self.layer.backgroundColor = [NSColor.labelColor colorWithAlphaComponent:0.08].CGColor; -} - -- (void)mouseExited:(NSEvent *)event -{ - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); -} - -- (void)updateTrackingAreas -{ - [super updateTrackingAreas]; - auto trackingAreas = [self.trackingAreas copy]; - for (NSTrackingArea *ta in trackingAreas) { - [self removeTrackingArea:ta]; - } - [trackingAreas release]; - - auto trackingArea = [[NSTrackingArea alloc] - initWithRect:self.bounds - options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways - owner:self - userInfo:nil]; - [self addTrackingArea:trackingArea]; - [trackingArea release]; -} - -@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 -{ - self = [super init]; - if (!self) return nil; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - - _hoverView = [[[NSView alloc] init] autorelease]; - _hoverView.wantsLayer = YES; - _hoverView.layer.backgroundColor = hoverColor().CGColor; - _hoverView.layer.cornerRadius = kHoverRadius; - _hoverView.hidden = YES; - [self addSubview:_hoverView]; - return self; -} - -- (void)updateHoverHighlight -{ - _hoverView.hidden = !(_mouseInside || _persistentHighlight); -} - -- (void)setPersistentHighlight:(BOOL)persistentHighlight -{ - _persistentHighlight = persistentHighlight; - [self updateHoverHighlight]; -} - -- (void)layout -{ - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, kAccountHoverVerticalMargin); -} - -- (void)mouseEntered:(NSEvent *)event -{ - _mouseInside = YES; - [self updateHoverHighlight]; - [self.popupDelegate onAccountRowHovered:self]; -} - -- (void)mouseExited:(NSEvent *)event -{ - _mouseInside = NO; - [self updateHoverHighlight]; -} - -- (void)mouseUp:(NSEvent *)event -{ - [self.popupDelegate onAccountRowClicked:self.userIndex]; -} - -@end - -@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 - 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 - 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; -- (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; -} - -- (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 -{ - 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; - _action = [action copy]; - _hoverAction = [hoverAction copy]; - _actionEnabled = enabled; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); - - _hoverView = [[[NSView alloc] init] autorelease]; - _hoverView.wantsLayer = YES; - _hoverView.layer.backgroundColor = hoverColor().CGColor; - _hoverView.layer.cornerRadius = kHoverRadius; - _hoverView.hidden = YES; - [self addSubview:_hoverView]; - - _label = [NSTextField labelWithString:title]; - 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; - - NSView *textContainer = nil; - NSTextField *subtitleLabel = nil; - NSTextField *dateTimeLabel = nil; - if (isPreviewRow) { - textContainer = [[[NSView alloc] init] autorelease]; - 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:@[ - [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] autorelease]; - 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], - ]]; - if (isPreviewRow) { - [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; - } else { - [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:chevron.leadingAnchor constant:-8]]; - } - } else { - if (isPreviewRow) { - [constraints addObject:[textContainer.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; - } else { - [constraints addObject:[_label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad]]; - } - } - - if (icon) { - _iconView = [[[NSImageView alloc] init] autorelease]; - _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:kActionIconSize], - [_iconView.heightAnchor constraintEqualToConstant:kActionIconSize], - ]]; - if (isPreviewRow) { - [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; - } else { - [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:8.0]]; - } - } else { - if (isPreviewRow) { - [constraints addObject:[textContainer.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; - } else { - [constraints addObject:[_label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; - } - } - - auto availableTextWidth = width - (kHPad * 2.0); - if (icon) { - availableTextWidth -= kActionIconSize + 8.0; - } - if (showsSubmenuIndicator) { - availableTextWidth -= 8.0 + 8.0; - } - - auto needsToolTip = labelLikelyClipsText(_label, title, availableTextWidth); - if (subtitleLabel) { - needsToolTip = needsToolTip || labelLikelyClipsText(subtitleLabel, subtitle, availableTextWidth); - } - if (dateTimeLabel) { - needsToolTip = needsToolTip || labelLikelyClipsText(dateTimeLabel, dateTime, availableTextWidth); - } - if (needsToolTip) { - auto toolTipViews = [NSMutableArray arrayWithObjects:self, _label, nil]; - if (textContainer) { - [toolTipViews addObject:textContainer]; - } - if (subtitleLabel) { - [toolTipViews addObject:subtitleLabel]; - } - if (dateTimeLabel) { - [toolTipViews addObject:dateTimeLabel]; - } - setSharedToolTip(menuRowToolTipText(title, subtitle, dateTime), toolTipViews); - } - - [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); -} - -- (void)setPersistentHighlight:(BOOL)persistentHighlight -{ - _persistentHighlight = persistentHighlight; - [self updateHoverHighlight]; -} - -- (void)layout -{ - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, 0.0); -} - -- (void)mouseEntered:(NSEvent *)event -{ - _mouseInside = YES; - [self updateHoverHighlight]; - if (_actionEnabled && _hoverAction) _hoverAction(self); -} - -- (void)mouseExited:(NSEvent *)event -{ - _mouseInside = NO; - [self updateHoverHighlight]; -} - -- (void)mouseUp:(NSEvent *)event -{ - if (_actionEnabled && _action) _action(); -} - -- (void)dealloc -{ - [_action release]; - [_hoverAction release]; - [super dealloc]; -} - -@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] autorelease]; - 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]]; - } - - auto availableTextWidth = width - (kHPad * 2.0); - if (icon) { - availableTextWidth -= kActivityPreviewIconSize + 8.0; - } - if (labelLikelyClipsText(label, title, availableTextWidth)) { - setSharedToolTip(title, @[self, label]); - } - - [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 = QCoreApplication::translate("TrayAccountPopup", "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], - ]]; - - const auto availableTextWidth = kPopupWidth - (kHPad + kAvatarSize + 10.0) - kHPad - 76.0 - 8.0; - if (labelLikelyClipsText(label, title, availableTextWidth)) { - setSharedToolTip(title, @[self, label]); - } - - 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] autorelease]; - 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 autorelease]; -} - -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; -@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: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)openAssistantForIndex:(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; - - _stack = configurePopupPanel(self); - return self; -} - -- (BOOL)canBecomeKeyWindow { return NO; } - -- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner -{ - clearStack(_stack); - - __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]]; - const auto appIconRequestedSize = pixelSizeForPointSize(kActionIconSize, backingScaleFactorForWindow(self)); - const auto appIconDevicePixelRatio = static_cast(appIconRequestedSize.width()) / kActionIconSize; - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]); - for (auto row = 0; row < appsModel->rowCount(); ++row) { - const auto appIndex = appsModel->index(row); - const auto appName = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::NameRole)).toString(); - const auto appUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::UrlRole)).toUrl(); - const auto appIconUrl = appsModel->data(appIndex, static_cast(OCC::TrayAccountAppsModel::Roles::IconUrlRole)).toUrl(); - const auto appIconCacheKey = remoteAppIconCacheKey(accountState, appIconUrl, appIconRequestedSize); - auto appIcon = nsImageFromQUrl(appIconUrl); - if (!appIcon && !appIconCacheKey.isEmpty() && s_remoteAppIconCache.contains(appIconCacheKey)) { - appIcon = nsImageFromQImage(s_remoteAppIconCache.value(appIconCacheKey)); - } - auto actionRow = [[NCActionRow alloc] initWithTitle:appName.toNSString() - icon:appIcon != nil ? appIcon : fallbackIcon - width:kAppsPopupWidth - enabled:YES - action:^{ - [weakOwner closeAllPopups]; - appsModel->openAppUrl(appUrl); - }]; - [actionRow setIconTintedToLabelColor:YES]; - [_stack addArrangedSubview:actionRow]; - - if (!appIcon && !appIconCacheKey.isEmpty()) { - auto retainedRow = [actionRow retain]; - auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); - QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow, appIconCacheKey, appIconRequestedSize, appIconDevicePixelRatio](const QByteArray &iconData) { - auto image = qImageFromImageData(iconData, appIconRequestedSize); - if (!image.isNull()) { - image.setDevicePixelRatio(appIconDevicePixelRatio); - s_remoteAppIconCache.insert(appIconCacheKey, image); - [retainedRow setIcon:nsImageFromQImage(image)]; - } - [retainedRow release]; - }); - QObject::connect(iconJob, &OCC::IconJob::error, iconJob, [retainedRow](auto) { - [retainedRow release]; - }); - } - [actionRow release]; - } - addOwnedArrangedSubview(_stack, [[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 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; - - _stack = configurePopupPanel(self); - return self; -} - -- (BOOL)canBecomeKeyWindow { return NO; } - -- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner -{ - clearStack(_stack); - - __unsafe_unretained NCTrayPopup *weakOwner = owner; - __unsafe_unretained NCNotificationActionsPopup *weakSelf = self; - addOwnedArrangedSubview(_stack, [[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; - } - - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:title.toNSString() - width:kNotificationActionsPopupWidth - enabled:YES - action:^{ - if (dismisses) { - OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); - [weakSelf orderOut:nil]; - return; - } - if (opensActivities) { - [weakOwner openActivitiesForIndex:userIndex]; - return; - } - - [weakOwner closeAllPopups]; - OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); - }]); - } - addOwnedArrangedSubview(_stack, [[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 -{ - self = [super initWithContentRect:NSMakeRect(0, 0, kAccountActionsPopupWidth, 1) - styleMask:NSWindowStyleMaskBorderless - backing:NSBackingStoreBuffered - defer:NO]; - if (!self) return nil; - - _userIndex = -1; - _stack = configurePopupPanel(self); - return self; -} - -- (BOOL)canBecomeKeyWindow { return NO; } - -- (void)dealloc -{ - [_appsPopup release]; - [_notificationActionsPopup release]; - if (_recentActivitiesConnection) { - QObject::disconnect(_recentActivitiesConnection); - } - [super dealloc]; -} - -- (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]; -} - -- (void)clearActiveSubmenuRow -{ - [_activeSubmenuRow setPersistentHighlight:NO]; - _activeSubmenuRow = nil; -} - -- (void)hideAppsPopup -{ - [_appsPopup orderOut:nil]; - [_notificationActionsPopup orderOut:nil]; - [self clearActiveSubmenuRow]; -} - -- (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex -{ - if (!_appsPopup) { - _appsPopup = [[NCAppsPopup alloc] init]; - } - - [_notificationActionsPopup orderOut:nil]; - [_appsPopup populateForUserIndex:userIndex owner:_owner]; - [self clearActiveSubmenuRow]; - if ([row isKindOfClass:[NCActionRow class]]) { - _activeSubmenuRow = (NCActionRow *)row; - [_activeSubmenuRow setPersistentHighlight:YES]; - } - - positionPopupFromRow(_appsPopup, row); - [_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]; - } - - positionPopupFromRow(_notificationActionsPopup, row); - [_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 -{ - const auto preserveTopEdge = [self isVisible]; - const auto topEdge = NSMaxY(self.frame); - - _owner = owner; - _userIndex = userIndex; - [_appsPopup orderOut:nil]; - [self clearActiveSubmenuRow]; - - clearStack(_stack); - - 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 serverHasUserStatus = model->data(userModelIndex, OCC::UserModel::ServerHasUserStatusRole).toBool(); - const auto onlineStatusEnabled = model->data(userModelIndex, OCC::UserModel::IsConnectedRole).toBool() - && 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(); - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); - 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()); - addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() - width:kAccountActionsPopupWidth]); - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() - icon:statusIcon - width:kAccountActionsPopupWidth - enabled:onlineStatusEnabled - action:^{ - [weakOwner openOnlineStatusForIndex:userIndex]; - } hoverAction:^(NSView *) { - [weakSelf hideAppsPopup]; - }]); - [_stack addArrangedSubview:accountActionsSeparator()]; - } - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Reveal in Finder").toNSString() - width:kAccountActionsPopupWidth - enabled:YES - action:^{ - [weakOwner openLocalFolderForIndex:userIndex]; - } hoverAction:^(NSView *) { - [weakSelf hideAppsPopup]; - }]); - if (assistantEnabled) { - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("MainWindow", "Open Assistant").toNSString() - width:kAccountActionsPopupWidth - enabled:YES - action:^{ - [weakOwner openAssistantForIndex:userIndex]; - } hoverAction:^(NSView *) { - [weakSelf hideAppsPopup]; - }]); - } - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayWindowHeader", "Apps").toNSString() - icon:nil - width:kAccountActionsPopupWidth - enabled:appsEnabled - action:^{} - hoverAction:^(NSView *row) { - [weakSelf showAppsPopupFromRow:row forUserIndex:userIndex]; - } showsSubmenuIndicator:YES]); - - [_stack addArrangedSubview:accountActionsSeparator()]; - - const auto trayNotifications = model->data(userModelIndex, OCC::UserModel::TrayNotificationsRole).toList(); - if (!trayNotifications.isEmpty()) { - addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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(); - addOwnedArrangedSubview(_stack, [[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:compactAccountActionsSeparator()]; - } - - addOwnedArrangedSubview(_stack, [[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Recent activity").toNSString() - width:kAccountActionsPopupWidth]); - const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); - if (recentActivities.isEmpty()) { - addOwnedArrangedSubview(_stack, [[NCStaticInfoRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "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; - } - addOwnedArrangedSubview(_stack, [[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]; - }]); - } - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246").toNSString() - width:kAccountActionsPopupWidth - enabled:YES - action:^{ - [weakOwner openActivitiesForIndex:userIndex]; - } hoverAction:^(NSView *) { - [weakSelf hideAppsPopup]; - }]); - - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]); - - [self.contentView layoutSubtreeIfNeeded]; - NSRect frame = self.frame; - frame.size.width = kAccountActionsPopupWidth; - frame.size.height = _stack.fittingSize.height; - if (preserveTopEdge) { - frame.origin.y = topEdge - frame.size.height; - } - auto screen = self.screen; - if (!screen) { - screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; - } - if (screen) { - frame.origin = clampedPopupOrigin(frame.origin, frame.size, screen.visibleFrame); - } - [self setFrame:frame display:NO]; - [self invalidateShadow]; - - if (refreshActivities) { - model->fetchActivityPreview(userIndex); - } -} - -@end - -@implementation NCTrayPopup { - NSStackView *_stack; - NCAccountActionsPopup *_accountActionsPopup; - NCAccountRow *_activeAccountRow; -} - -- (instancetype)init -{ - self = [super initWithContentRect:NSMakeRect(0, 0, kPopupWidth, 120) - styleMask:NSWindowStyleMaskBorderless - backing:NSBackingStoreBuffered - defer:NO]; - if (!self) return nil; - - _stack = configurePopupPanel(self); - return self; -} - -- (BOOL)canBecomeKeyWindow { return YES; } - -- (void)dealloc -{ - [_accountActionsPopup release]; - [super dealloc]; -} - -- (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 - avatar:(NSImage *)avatar - syncStatusImage:(NSImage *)syncStatusImage -{ - NSImageView *avatarView = [[[NSImageView alloc] init] autorelease]; - avatarView.image = avatar != nil ? avatar : [NSImage imageWithSystemSymbolName:@"person.circle.fill" - accessibilityDescription:nil]; - avatarView.wantsLayer = YES; - avatarView.layer.cornerRadius = kAvatarSize / 2.0; - avatarView.layer.masksToBounds = YES; - avatarView.imageScaling = NSImageScaleProportionallyUpOrDown; - avatarView.translatesAutoresizingMaskIntoConstraints = NO; - - NSTextField *nameLabel = [NSTextField labelWithString:name]; - nameLabel.font = [NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; - nameLabel.textColor = NSColor.labelColor; - nameLabel.lineBreakMode = NSLineBreakByTruncatingTail; - nameLabel.translatesAutoresizingMaskIntoConstraints = NO; - - NSTextField *serverLabel = [NSTextField labelWithString:server]; - serverLabel.font = [NSFont systemFontOfSize:11]; - serverLabel.textColor = NSColor.secondaryLabelColor; - serverLabel.lineBreakMode = NSLineBreakByTruncatingTail; - serverLabel.translatesAutoresizingMaskIntoConstraints = NO; - - NCAccountRow *row = [[[NCAccountRow alloc] init] autorelease]; - row.userIndex = index; - row.popupDelegate = self; - - NSImageView *statusView = [[[NSImageView alloc] init] autorelease]; - statusView.image = syncStatusImage; - statusView.translatesAutoresizingMaskIntoConstraints = NO; - - NSImageView *chevron = [[[NSImageView alloc] init] autorelease]; - chevron.image = [[NSImage imageWithSystemSymbolName:@"chevron.right" accessibilityDescription:nil] - imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:11 weight:NSFontWeightMedium]]; - chevron.contentTintColor = NSColor.tertiaryLabelColor; - chevron.translatesAutoresizingMaskIntoConstraints = NO; - - [row addSubview:avatarView]; - [row addSubview:nameLabel]; - [row addSubview:serverLabel]; - [row addSubview:statusView]; - [row addSubview:chevron]; - - [NSLayoutConstraint activateConstraints:@[ - [avatarView.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:kHPad], - [avatarView.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], - [avatarView.widthAnchor constraintEqualToConstant:kAvatarSize], - [avatarView.heightAnchor constraintEqualToConstant:kAvatarSize], - - [nameLabel.leadingAnchor constraintEqualToAnchor:avatarView.trailingAnchor constant:10], - [nameLabel.topAnchor constraintEqualToAnchor:row.topAnchor constant:9], - [nameLabel.trailingAnchor constraintLessThanOrEqualToAnchor:statusView.leadingAnchor constant:-8], - - [serverLabel.leadingAnchor constraintEqualToAnchor:nameLabel.leadingAnchor], - [serverLabel.topAnchor constraintEqualToAnchor:nameLabel.bottomAnchor constant:2], - [serverLabel.trailingAnchor constraintLessThanOrEqualToAnchor:statusView.leadingAnchor constant:-8], - - [chevron.trailingAnchor constraintEqualToAnchor:row.trailingAnchor constant:-kHPad], - [chevron.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], - [chevron.widthAnchor constraintEqualToConstant:8], - [chevron.heightAnchor constraintEqualToConstant:13], - - [statusView.trailingAnchor constraintEqualToAnchor:chevron.leadingAnchor constant:-8], - [statusView.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], - [statusView.widthAnchor constraintEqualToConstant:18], - [statusView.heightAnchor constraintEqualToConstant:18], - - [row.heightAnchor constraintEqualToConstant:kRowHeight], - [row.widthAnchor constraintEqualToConstant:kPopupWidth], - ]]; - - const auto labelAvailableWidth = kPopupWidth - kHPad - kAvatarSize - 10.0 - 8.0 - 18.0 - 8.0 - 8.0 - kHPad; - if (labelLikelyClipsText(nameLabel, name, labelAvailableWidth) || labelLikelyClipsText(serverLabel, server, labelAvailableWidth)) { - setSharedToolTip(menuRowToolTipText(name, server, nil), @[row, nameLabel, serverLabel]); - } - - return row; -} - -- (void)populate -{ - [_accountActionsPopup orderOut:nil]; - [self clearActiveAccountRow]; - - clearStack(_stack); - - OCC::UserModel *model = OCC::UserModel::instance(); - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kTopPadding]); - for (int i = 0; i < model->rowCount(); ++i) { - const QModelIndex idx = model->index(i); - NSString *name = model->data(idx, OCC::UserModel::NameRole).toString().toNSString(); - 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()) { - addOwnedArrangedSubview(_stack, [[NCAlertBoxRow alloc] initWithTitle:accountAlertTitle.toNSString() - action:^{ - [self openActivitiesForIndex:i]; - } hoverAction:^(NSView *) { - [self closeAccountActionsPopup]; - }]); - } - } - - if (model->rowCount() > 0) { - NSBox *sep = [[NSBox alloc] init]; - sep.boxType = NSBoxSeparator; - sep.translatesAutoresizingMaskIntoConstraints = NO; - [_stack addArrangedSubview:sep]; - [sep.widthAnchor constraintEqualToConstant:kPopupWidth].active = YES; - [sep release]; - } - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); - - __unsafe_unretained NCTrayPopup *weakSelf = self; - if (OCC::Systray::instance()->enableAddAccount()) { - addOwnedArrangedSubview(_stack, [[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]; - }]); - } - addOwnedArrangedSubview(_stack, [[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]; - }]); - addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() - width:kPopupWidth - enabled:YES - action:^{ - OCC::Systray::instance()->shutdown(); - } hoverAction:^(NSView *) { - [weakSelf closeAccountActionsPopup]; - }]); - addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]); - - [self.contentView layoutSubtreeIfNeeded]; - NSRect frame = self.frame; - frame.size.height = _stack.fittingSize.height; - [self setFrame:frame display:NO]; - [self invalidateShadow]; -} - -- (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]; - - positionPopupFromRow(_accountActionsPopup, row); - [_accountActionsPopup orderFront:nil]; -} - -- (void)openActivitiesForIndex:(int)index -{ - [_accountActionsPopup orderOut:nil]; - [self orderOut:nil]; - OCC::Systray::instance()->setIsOpen(false); - OCC::Systray::instance()->showActivitiesWindow(index); -} - -- (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)openAssistantForIndex:(int)index -{ - [_accountActionsPopup orderOut:nil]; - [self orderOut:nil]; - OCC::Systray::instance()->showAssistantWindow(index); -} - -- (void)openOnlineStatusForIndex:(int)index -{ - [_accountActionsPopup orderOut:nil]; - [self orderOut:nil]; - OCC::Systray::instance()->setIsOpen(false); - OCC::Systray::instance()->showUserStatusWindow(index); -} - -@end - -namespace OCC { - -static NCTrayPopup *s_popup = nil; - -void showMacOSTrayPopup(const QRect &iconRect) -{ - if (!s_popup) { - s_popup = [[NCTrayPopup alloc] init]; - } - - [s_popup populate]; - - auto qtScreen = iconRect.isValid() && !iconRect.isNull() - ? QGuiApplication::screenAt(iconRect.center()) - : nullptr; - NSScreen *screen = nsScreenForQtScreen(qtScreen); - if (!screen) { - screen = NSScreen.screens.firstObject; - } - - const CGFloat popupW = s_popup.frame.size.width; - const CGFloat popupH = s_popup.frame.size.height; - const NSRect visibleFrame = screen.visibleFrame; - - CGFloat x, y; - if (iconRect.isValid() && !iconRect.isNull() && qtScreen) { - const auto qtScreenGeometry = qtScreen->geometry(); - x = NSMinX(screen.frame) + iconRect.x() - qtScreenGeometry.x() - kStatusItemLeadingOffset; - y = NSMaxY(screen.frame) - (iconRect.y() + iconRect.height() - qtScreenGeometry.y()) - popupH; - } else { - x = NSMaxX(visibleFrame) - popupW - kScreenEdgePadding; - y = NSMaxY(visibleFrame) - popupH; - } - - const auto popupOrigin = clampedPopupOrigin(NSMakePoint(x, y), NSMakeSize(popupW, popupH), visibleFrame); - - [s_popup setFrameOrigin:popupOrigin]; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - [NSApp activateIgnoringOtherApps:YES]; -#pragma clang diagnostic pop - [s_popup makeKeyAndOrderFront:nil]; -} - -void hideMacOSTrayPopup() -{ - [s_popup orderOut:nil]; -} - -} // namespace OCC diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index bc882330c97e2..68ecd48ae4ef5 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -46,7 +46,7 @@ using namespace Qt::StringLiterals; namespace OCC { -// Keep behavior and menu taxonomy aligned with src/gui/macOS/trayaccountpopup_mac.mm. +// Keep behavior and menu taxonomy aligned with the macOS popup in src/gui/macOS/trayaccountpopup/. namespace {