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 b68e5fd9287e4..961556fa0db1f 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -220,6 +220,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 @@ -338,7 +340,10 @@ IF( APPLE ) install(DIRECTORY "${SPARKLE_LIBRARY}" DESTINATION "${OWNCLOUD_OSX_BUNDLE}/Contents/Frameworks" USE_SOURCE_PERMISSIONS) - endif() + endif() +ENDIF() +IF( NOT APPLE ) + list(APPEND client_SRCS trayaccountpopup_qt.cpp) ENDIF() IF( NOT WIN32 AND NOT APPLE ) diff --git a/src/gui/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 3f508659ba576..f8c1166bd0aaf 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -845,7 +845,7 @@ void Application::slotownCloudWizardDone(int res) Utility::setLaunchOnStartup(_theme->appName(), _theme->appNameGUI(), true); - Systray::instance()->showWindow(); + Systray::instance()->showTrayPopup(); } } diff --git a/src/gui/macOS/trayaccountpopup_mac.mm b/src/gui/macOS/trayaccountpopup_mac.mm index 67e1a07a719fd..11a4338d7d252 100644 --- a/src/gui/macOS/trayaccountpopup_mac.mm +++ b/src/gui/macOS/trayaccountpopup_mac.mm @@ -4,19 +4,29 @@ */ #include "systray.h" +#include "accountmanager.h" +#include "iconjob.h" +#include "tray/trayaccountappsmodel.h" #include "tray/usermodel.h" +#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; @@ -26,6 +36,14 @@ 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 NSColor *hoverColor() { @@ -58,6 +76,92 @@ return img; } +static QImage qImageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + if (imageData.isEmpty()) return {}; + + const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); + if (mimetype.isValid() && mimetype.inherits(QStringLiteral("image/svg+xml"))) { + QSvgRenderer renderer; + if (!renderer.load(imageData)) return {}; + + auto image = QImage(requestedSize, QImage::Format_ARGB32); + image.fill(Qt::transparent); + QPainter painter(&image); + const auto scaledSize = renderer.defaultSize().scaled(requestedSize, Qt::KeepAspectRatio); + const auto targetRect = QRectF(QPointF((requestedSize.width() - scaledSize.width()) / 2.0, + (requestedSize.height() - scaledSize.height()) / 2.0), + scaledSize); + renderer.render(&painter, targetRect); + return image; + } + + auto image = QImage::fromData(imageData); + if (!image.isNull() && requestedSize.isValid()) { + image = image.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return image; +} + +static NSImage *nsImageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + return nsImageFromQImage(qImageFromImageData(imageData, requestedSize)); +} + +static QImage qImageFromQUrl(const QUrl &url) +{ + if (url.isEmpty()) return {}; + + 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 @@ -73,167 +177,1394 @@ - (instancetype)init return self; } -- (void)mouseEntered:(NSEvent *)event -{ - self.layer.backgroundColor = [NSColor.labelColor colorWithAlphaComponent:0.08].CGColor; -} +- (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]; + for (NSTrackingArea *ta in self.trackingAreas.copy) [self removeTrackingArea:ta]; + [self addTrackingArea:[[NSTrackingArea alloc] + initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]]; +} + +@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]; + _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]; + _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]; + 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]; + 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]; + _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]]; + } + } + + [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(); +} + +@end + +@interface NCSectionHeaderRow : NSView +- (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width; +@end + +@implementation NCSectionHeaderRow + +- (instancetype)initWithTitle:(NSString *)title width:(CGFloat)width +{ + self = [super init]; + if (!self) return nil; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; + label.textColor = NSColor.secondaryLabelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [self.heightAnchor constraintEqualToConstant:kSectionHeaderHeight], + [self.widthAnchor constraintEqualToConstant:width], + [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad], + [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + ]]; + return self; +} + +@end + +@interface NCStaticInfoRow : NSView +- (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width; +@end + +@implementation NCStaticInfoRow + +- (instancetype)initWithTitle:(NSString *)title icon:(NSImage *)icon width:(CGFloat)width +{ + self = [super init]; + if (!self) return nil; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:13]; + label.textColor = NSColor.labelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + auto constraints = [NSMutableArray arrayWithArray:@[ + [self.heightAnchor constraintEqualToConstant:kActionHeight], + [self.widthAnchor constraintEqualToConstant:width], + [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [label.trailingAnchor constraintLessThanOrEqualToAnchor:self.trailingAnchor constant:-kHPad], + ]]; + + if (icon) { + auto iconView = [[NSImageView alloc] init]; + iconView.image = icon; + iconView.contentTintColor = NSColor.secondaryLabelColor; + iconView.imageScaling = NSImageScaleProportionallyUpOrDown; + iconView.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:iconView]; + [constraints addObjectsFromArray:@[ + [iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], + [iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], + [iconView.widthAnchor constraintEqualToConstant:kActivityPreviewIconSize], + [iconView.heightAnchor constraintEqualToConstant:kActivityPreviewIconSize], + [label.leadingAnchor constraintEqualToAnchor:iconView.trailingAnchor constant:8.0], + ]]; + } else { + [constraints addObject:[label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad]]; + } + + [NSLayoutConstraint activateConstraints:constraints]; + return self; +} + +@end + +@interface NCPointingHandButton : NSButton +@end + +@implementation NCPointingHandButton + +- (void)resetCursorRects +{ + [super resetCursorRects]; + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; +} + +@end + +@interface NCAlertBoxRow : NSView +- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction; +@end + +@implementation NCAlertBoxRow { + dispatch_block_t _action; + NCActionHoverBlock _hoverAction; +} + +- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action hoverAction:(NCActionHoverBlock)hoverAction +{ + self = [super init]; + if (!self) return nil; + + _action = [action copy]; + _hoverAction = [hoverAction copy]; + self.translatesAutoresizingMaskIntoConstraints = NO; + + auto label = [NSTextField labelWithString:title]; + label.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; + label.textColor = NSColor.labelColor; + label.lineBreakMode = NSLineBreakByTruncatingTail; + label.maximumNumberOfLines = 2; + label.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:label]; + + auto resolveButton = [[[NCPointingHandButton alloc] init] autorelease]; + resolveButton.title = 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], + ]]; + + return self; +} + +- (void)dealloc +{ + [_action release]; + [_hoverAction release]; + [super dealloc]; +} + +- (void)updateTrackingAreas +{ + [super updateTrackingAreas]; + auto trackingAreas = [self.trackingAreas copy]; + for (NSTrackingArea *area in trackingAreas) { + [self removeTrackingArea:area]; + } + [trackingAreas release]; + + auto trackingArea = [[NSTrackingArea alloc] initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways + owner:self + userInfo:nil]; + [self addTrackingArea:trackingArea]; + [trackingArea release]; +} + +- (void)mouseEntered:(NSEvent *)event +{ + if (_hoverAction) { + _hoverAction(self); + } +} + +- (void)mouseUp:(NSEvent *)event +{ + if (_action) { + _action(); + } +} + +- (void)resolveButtonClicked:(id)sender +{ + if (_action) { + _action(); + } +} + +@end + +static NSView *accountActionsSeparator(const CGFloat verticalMargin) +{ + auto separator = [[NSBox alloc] init]; + separator.boxType = NSBoxSeparator; + separator.translatesAutoresizingMaskIntoConstraints = NO; + + auto container = [[NSView alloc] init]; + container.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:separator]; + + [NSLayoutConstraint activateConstraints:@[ + [container.widthAnchor constraintEqualToConstant:kAccountActionsPopupWidth], + [container.heightAnchor constraintEqualToConstant:(2.0 * verticalMargin) + 1.0], + [separator.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [separator.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [separator.centerYAnchor constraintEqualToAnchor:container.centerYAnchor], + [separator.heightAnchor constraintEqualToConstant:1.0], + ]]; + return container; +} + +static NSView *accountActionsSeparator() +{ + return accountActionsSeparator(kAccountHoverVerticalMargin); +} + +static NSView *compactAccountActionsSeparator() +{ + return accountActionsSeparator(kCompactSeparatorVerticalMargin); +} + +@interface NCSpacerView : NSView +- (instancetype)initWithHeight:(CGFloat)height; +- (instancetype)initWithHeight:(CGFloat)height width:(CGFloat)width; +@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; + + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } + +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner +{ + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } + + __unsafe_unretained NCTrayPopup *weakOwner = owner; + auto appsModel = OCC::TrayAccountAppsModel::instance(); + appsModel->setUserId(userIndex); + const auto accounts = OCC::AccountManager::instance()->accounts(); + const auto accountState = userIndex >= 0 && userIndex < accounts.size() + ? accounts.at(userIndex) + : OCC::AccountStatePtr{}; + auto fallbackIcon = [[NSImage imageWithSystemSymbolName:@"app" accessibilityDescription:nil] + imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightRegular]]; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; + for (auto row = 0; row < appsModel->rowCount(); ++row) { + const auto appIndex = appsModel->index(row); + const auto appUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::UrlRole).toUrl(); + const auto appIconUrl = appsModel->data(appIndex, OCC::TrayAccountAppsModel::IconUrlRole).toUrl(); + auto appIcon = nsImageFromQUrl(appIconUrl); + auto actionRow = [[NCActionRow alloc] initWithTitle:appsModel->data(appIndex, OCC::TrayAccountAppsModel::NameRole).toString().toNSString() + icon:appIcon != nil ? appIcon : fallbackIcon + width:kAppsPopupWidth + enabled:YES + action:^{ + [weakOwner closeAllPopups]; + appsModel->openAppUrl(appUrl); + }]; + [actionRow setIconTintedToLabelColor:YES]; + [_stack addArrangedSubview:actionRow]; + + if (!appIcon && accountState && accountState->account() && appIconUrl.isValid() && !appIconUrl.scheme().isEmpty()) { + auto retainedRow = [actionRow retain]; + auto iconJob = new OCC::IconJob(accountState->account(), appIconUrl); + QObject::connect(iconJob, &OCC::IconJob::jobFinished, iconJob, [retainedRow](const QByteArray &iconData) { + [retainedRow setIcon:nsImageFromImageData(iconData, QSize(18, 18))]; + [retainedRow release]; + }); + QObject::connect(iconJob, &OCC::IconJob::error, iconJob, [retainedRow](auto) { + [retainedRow release]; + }); + } + } + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAppsPopupWidth]]; + + [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; + + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; + return self; +} + +- (BOOL)canBecomeKeyWindow { return NO; } -- (void)mouseExited:(NSEvent *)event +- (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions owner:(NCTrayPopup *)owner { - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); -} + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } -- (void)updateTrackingAreas -{ - [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]]; -} + __unsafe_unretained NCTrayPopup *weakOwner = owner; + __unsafe_unretained NCNotificationActionsPopup *weakSelf = self; + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]]; + for (const auto &actionVariant : actions) { + const auto actionData = actionVariant.toMap(); + const auto title = actionData.value(QStringLiteral("label")).toString(); + if (title.isEmpty()) { + continue; + } -@end + const auto actionType = actionData.value(QStringLiteral("actionType")).toString(); + const auto actionIndex = actionData.value(QStringLiteral("actionIndex")).toInt(); + const auto dismisses = actionType == QStringLiteral("dismiss"); + const auto opensActivities = actionType == QStringLiteral("openActivities"); + if (!dismisses && !opensActivities && actionIndex < 0) { + continue; + } + + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + width:kNotificationActionsPopupWidth + enabled:YES + action:^{ + if (dismisses) { + OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); + [weakSelf orderOut:nil]; + return; + } + if (opensActivities) { + [weakOwner openActivitiesForIndex:userIndex]; + return; + } + + [weakOwner closeAllPopups]; + OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); + }]]; + } + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]]; + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kNotificationActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + [self setFrame:frame display:NO]; + [self invalidateShadow]; +} -@protocol NCAccountRowDelegate -- (void)onAccountRowClicked:(int)index; @end -@interface NCAccountRow : NCHoverView -@property (nonatomic, assign) int userIndex; -@property (nonatomic, assign) id popupDelegate; +@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 NCAccountRow { - NSView *_hoverView; +@implementation NCAccountActionsPopup { + NSStackView *_stack; + NCAppsPopup *_appsPopup; + NCNotificationActionsPopup *_notificationActionsPopup; + NCActionRow *_activeSubmenuRow; + __unsafe_unretained NCTrayPopup *_owner; + QMetaObject::Connection _recentActivitiesConnection; + int _userIndex; } - (instancetype)init { - self = [super init]; + self = [super initWithContentRect:NSMakeRect(0, 0, kAccountActionsPopupWidth, 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]; + self.level = NSPopUpMenuWindowLevel; + self.hasShadow = YES; + self.releasedWhenClosed = NO; + self.backgroundColor = NSColor.clearColor; + self.opaque = NO; + _userIndex = -1; + + NSView *container = [[NSView alloc] init]; + container.wantsLayer = YES; + container.layer.cornerRadius = kCornerRadius; + container.layer.masksToBounds = YES; + self.contentView = container; + + NSVisualEffectView *vev = [[NSVisualEffectView alloc] init]; + vev.material = NSVisualEffectMaterialHUDWindow; + vev.blendingMode = NSVisualEffectBlendingModeBehindWindow; + vev.state = NSVisualEffectStateActive; + vev.wantsLayer = YES; + vev.layer.cornerRadius = kCornerRadius; + vev.layer.masksToBounds = YES; + vev.translatesAutoresizingMaskIntoConstraints = NO; + [container addSubview:vev]; + [NSLayoutConstraint activateConstraints:@[ + [vev.topAnchor constraintEqualToAnchor:container.topAnchor], + [vev.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], + [vev.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], + [vev.bottomAnchor constraintEqualToAnchor:container.bottomAnchor], + ]]; + + _stack = [NSStackView stackViewWithViews:@[]]; + _stack.orientation = NSUserInterfaceLayoutOrientationVertical; + _stack.spacing = 0; + _stack.translatesAutoresizingMaskIntoConstraints = NO; + [vev addSubview:_stack]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.topAnchor constraintEqualToAnchor:vev.topAnchor], + [_stack.leadingAnchor constraintEqualToAnchor:vev.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:vev.trailingAnchor], + [_stack.bottomAnchor constraintEqualToAnchor:vev.bottomAnchor], + ]]; return self; } -- (void)layout +- (BOOL)canBecomeKeyWindow { return NO; } + +- (BOOL)isShowingActivitiesForUserIndex:(int)userIndex { - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, kAccountHoverVerticalMargin); + 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 { - [self.popupDelegate onAccountRowClicked:self.userIndex]; + [_appsPopup orderOut:nil]; + [_notificationActionsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; } -@end +- (void)showAppsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex +{ + if (!_appsPopup) { + _appsPopup = [[NCAppsPopup alloc] init]; + } -@interface NCActionRow : NCHoverView -- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action; -@end + [_notificationActionsPopup orderOut:nil]; + [_appsPopup populateForUserIndex:userIndex owner:_owner]; + [self clearActiveSubmenuRow]; + if ([row isKindOfClass:[NCActionRow class]]) { + _activeSubmenuRow = (NCActionRow *)row; + [_activeSubmenuRow setPersistentHighlight:YES]; + } -@implementation NCActionRow { - dispatch_block_t _action; - NSView *_hoverView; + const auto rowTopLeftInWindow = [row convertPoint:NSMakePoint(NSMinX(row.bounds) + kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopRightInWindow = [row convertPoint:NSMakePoint(NSMaxX(row.bounds) - kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopLeftOnScreen = [row.window convertPointToScreen:rowTopLeftInWindow]; + auto rowTopRightOnScreen = [row.window convertPointToScreen:rowTopRightInWindow]; + + const auto popupWidth = _appsPopup.frame.size.width; + const auto popupHeight = _appsPopup.frame.size.height; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= popupHeight; + + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + const auto topEdge = NSMaxY(visibleFrame) - kScreenEdgePadding; + const auto bottomEdge = NSMinY(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + popupOrigin.y = popupOrigin.y < bottomEdge ? bottomEdge : (popupOrigin.y + popupHeight > topEdge ? topEdge - popupHeight : popupOrigin.y); + + [_appsPopup setFrameOrigin:popupOrigin]; + [_appsPopup orderFront:nil]; } -- (instancetype)initWithTitle:(NSString *)title action:(dispatch_block_t)action +- (void)showNotificationActionsPopupFromRow:(NSView *)row forUserIndex:(int)userIndex activityIndex:(int)activityIndex actions:(QVariantList)actions { - self = [super init]; - if (!self) return nil; - _action = [action copy]; - self.layer.backgroundColor = CGColorGetConstantColor(kCGColorClear); + if (!_notificationActionsPopup) { + _notificationActionsPopup = [[NCNotificationActionsPopup alloc] init]; + } - _hoverView = [[NSView alloc] init]; - _hoverView.wantsLayer = YES; - _hoverView.layer.backgroundColor = hoverColor().CGColor; - _hoverView.layer.cornerRadius = kHoverRadius; - _hoverView.hidden = YES; - [self addSubview:_hoverView]; + [_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]; + } - NSTextField *label = [NSTextField labelWithString:title]; - label.font = [NSFont systemFontOfSize:13]; - label.textColor = NSColor.labelColor; - label.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:label]; + 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]; - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:kHPad], - [label.centerYAnchor constraintEqualToAnchor:self.centerYAnchor], - [self.heightAnchor constraintEqualToConstant:kActionHeight], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], - ]]; - return self; -} + const auto popupWidth = _notificationActionsPopup.frame.size.width; + const auto popupHeight = _notificationActionsPopup.frame.size.height; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= popupHeight; -- (void)layout -{ - [super layout]; - _hoverView.frame = NSInsetRect(self.bounds, kHoverMargin, 0.0); -} + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + const auto topEdge = NSMaxY(visibleFrame) - kScreenEdgePadding; + const auto bottomEdge = NSMinY(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + popupOrigin.y = popupOrigin.y < bottomEdge ? bottomEdge : (popupOrigin.y + popupHeight > topEdge ? topEdge - popupHeight : popupOrigin.y); -- (void)mouseEntered:(NSEvent *)event -{ - _hoverView.hidden = NO; + [_notificationActionsPopup setFrameOrigin:popupOrigin]; + [_notificationActionsPopup orderFront:nil]; } -- (void)mouseExited:(NSEvent *)event +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner { - _hoverView.hidden = YES; + [self populateForUserIndex:userIndex owner:owner refreshActivities:YES]; } -- (void)mouseUp:(NSEvent *)event +- (void)populateForUserIndex:(int)userIndex owner:(NCTrayPopup *)owner refreshActivities:(BOOL)refreshActivities { - if (_action) _action(); -} + const auto preserveTopEdge = [self isVisible]; + const auto topEdge = NSMaxY(self.frame); -@end + _owner = owner; + _userIndex = userIndex; + [_appsPopup orderOut:nil]; + [self clearActiveSubmenuRow]; -@interface NCSpacerView : NSView -- (instancetype)initWithHeight:(CGFloat)height; -@end + for (NSView *v in _stack.arrangedSubviews.copy) { + [_stack removeArrangedSubview:v]; + [v removeFromSuperview]; + } -@implementation NCSpacerView + auto model = OCC::UserModel::instance(); + if (_recentActivitiesConnection) { + QObject::disconnect(_recentActivitiesConnection); + _recentActivitiesConnection = {}; + } + if (!model || userIndex < 0 || userIndex >= model->rowCount()) { + return; + } -- (instancetype)initWithHeight:(CGFloat)height -{ - self = [super init]; - if (!self) return nil; - self.translatesAutoresizingMaskIntoConstraints = NO; - [NSLayoutConstraint activateConstraints:@[ - [self.heightAnchor constraintEqualToConstant:height], - [self.widthAnchor constraintEqualToConstant:kPopupWidth], - ]]; - return self; -} + __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(); + [_stack addArrangedSubview:[[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()); + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "User status").toNSString() + width:kAccountActionsPopupWidth]]; + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:statusMenuText(status, statusMessage).toNSString() + icon:statusIcon + width:kAccountActionsPopupWidth + enabled:onlineStatusEnabled + action:^{ + [weakOwner openOnlineStatusForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + [_stack addArrangedSubview:accountActionsSeparator()]; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openLocalFolderForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + if (assistantEnabled) { + [_stack addArrangedSubview:[[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]; + }]]; + } + [_stack addArrangedSubview:[[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()) { + [_stack addArrangedSubview:[[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(); + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + icon:systemSymbolImage(notificationData.value(QStringLiteral("systemIconName")).toString(), 14.0) + dateTime:notificationData.value(QStringLiteral("dateTime")).toString().toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + if (opensSettings) { + [weakOwner closeAllPopups]; + OCC::Systray::instance()->openSettings(); + } else { + [weakOwner openActivitiesForIndex:userIndex]; + } + } hoverAction:^(NSView *row) { + if (!notificationActions.isEmpty()) { + [weakSelf showNotificationActionsPopupFromRow:row forUserIndex:userIndex activityIndex:activityIndex actions:notificationActions]; + } else { + [weakSelf hideAppsPopup]; + } + } showsSubmenuIndicator:!notificationActions.isEmpty()]]; + } -@end + [_stack addArrangedSubview:compactAccountActionsSeparator()]; + } + + [_stack addArrangedSubview:[[NCSectionHeaderRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "Recent activity").toNSString() + width:kAccountActionsPopupWidth]]; + const auto recentActivities = model->data(userModelIndex, OCC::UserModel::RecentActivitiesRole).toList(); + if (recentActivities.isEmpty()) { + [_stack addArrangedSubview:[[NCStaticInfoRow alloc] initWithTitle: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; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:title.toNSString() + icon:systemSymbolImage(activityData.value(QStringLiteral("systemIconName")).toString(), 14.0) + subtitle:activityData.value(QStringLiteral("subtitle")).toString().toNSString() + dateTime:activityData.value(QStringLiteral("dateTime")).toString().toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + } + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246").toNSString() + width:kAccountActionsPopupWidth + enabled:YES + action:^{ + [weakOwner openActivitiesForIndex:userIndex]; + } hoverAction:^(NSView *) { + [weakSelf hideAppsPopup]; + }]]; + + [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kAccountActionsPopupWidth]]; + + [self.contentView layoutSubtreeIfNeeded]; + NSRect frame = self.frame; + frame.size.width = kAccountActionsPopupWidth; + frame.size.height = _stack.fittingSize.height; + if (preserveTopEdge) { + frame.origin.y = topEdge - frame.size.height; + } + [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 @@ -291,10 +1622,32 @@ - (BOOL)canBecomeKeyWindow { return YES; } - (void)resignKeyWindow { [super resignKeyWindow]; + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); +} + +- (void)closeAllPopups +{ + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; [self orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); } +- (void)closeAccountActionsPopup +{ + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; +} + +- (void)clearActiveAccountRow +{ + [_activeAccountRow setPersistentHighlight:NO]; + _activeAccountRow = nil; +} + - (NCAccountRow *)makeRowForIndex:(int)index name:(NSString *)name server:(NSString *)server @@ -375,6 +1728,9 @@ - (NCAccountRow *)makeRowForIndex:(int)index - (void)populate { + [_accountActionsPopup orderOut:nil]; + [self clearActiveAccountRow]; + for (NSView *v in _stack.arrangedSubviews.copy) { [_stack removeArrangedSubview:v]; [v removeFromSuperview]; @@ -388,7 +1744,21 @@ - (void)populate NSString *server = model->data(idx, OCC::UserModel::ServerRole).toString().toNSString(); NSImage *avatar = nsImageFromQImage(model->avatarForRow(i)); NSImage *syncStatus = nsImageFromQImage(model->syncStatusIconForRow(i)); - [_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()) { + [_stack addArrangedSubview:[[NCAlertBoxRow alloc] initWithTitle:accountAlertTitle.toNSString() + action:^{ + [self openActivitiesForIndex:i]; + } hoverAction:^(NSView *) { + [self closeAccountActionsPopup]; + }]]; + } } if (model->rowCount() > 0) { @@ -402,19 +1772,34 @@ - (void)populate __unsafe_unretained NCTrayPopup *weakSelf = self; if (OCC::Systray::instance()->enableAddAccount()) { - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Add account").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Add account").toNSString() + width:kPopupWidth + enabled:YES + action:^{ [weakSelf orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); OCC::Systray::instance()->openAccountWizard(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; } - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Settings").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Settings").toNSString() + width:kPopupWidth + enabled:YES + action:^{ [weakSelf orderOut:nil]; OCC::Systray::instance()->setIsOpen(false); OCC::Systray::instance()->openSettings(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; - [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() action:^{ + [_stack addArrangedSubview:[[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Quit").toNSString() + width:kPopupWidth + enabled:YES + action:^{ OCC::Systray::instance()->shutdown(); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; }]]; [_stack addArrangedSubview:[[NCSpacerView alloc] initWithHeight:kActionVerticalPadding]]; @@ -427,10 +1812,93 @@ - (void)populate - (void)onAccountRowClicked:(int)index { + [self openActivitiesForIndex:index]; +} + +- (void)onAccountRowHovered:(NCAccountRow *)row +{ + if (!_accountActionsPopup) { + _accountActionsPopup = [[NCAccountActionsPopup alloc] init]; + } + + if (_activeAccountRow != row) { + [self clearActiveAccountRow]; + _activeAccountRow = row; + [_activeAccountRow setPersistentHighlight:YES]; + } + + [_accountActionsPopup populateForUserIndex:row.userIndex owner:self]; + + const auto rowTopLeftInWindow = [row convertPoint:NSMakePoint(NSMinX(row.bounds) + kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopRightInWindow = [row convertPoint:NSMakePoint(NSMaxX(row.bounds) - kHPad, NSMaxY(row.bounds)) toView:nil]; + const auto rowTopLeftOnScreen = [row.window convertPointToScreen:rowTopLeftInWindow]; + auto rowTopRightOnScreen = [row.window convertPointToScreen:rowTopRightInWindow]; + + const auto popupWidth = _accountActionsPopup.frame.size.width; + auto popupOrigin = rowTopRightOnScreen; + popupOrigin.y -= _accountActionsPopup.frame.size.height; + + auto screen = row.window.screen; + if (!screen) { + screen = NSScreen.mainScreen ?: NSScreen.screens.firstObject; + } + const auto visibleFrame = screen.visibleFrame; + const auto rightEdge = NSMaxX(visibleFrame) - kScreenEdgePadding; + const auto leftEdge = NSMinX(visibleFrame) + kScreenEdgePadding; + + if (popupOrigin.x + popupWidth > rightEdge && rowTopLeftOnScreen.x - popupWidth >= leftEdge) { + popupOrigin.x = rowTopLeftOnScreen.x - popupWidth; + } + popupOrigin.x = popupOrigin.x < leftEdge ? leftEdge : (popupOrigin.x + popupWidth > rightEdge ? rightEdge - popupWidth : popupOrigin.x); + + [_accountActionsPopup setFrameOrigin:popupOrigin]; + [_accountActionsPopup orderFront:nil]; +} + +- (void)openActivitiesForIndex:(int)index +{ + [_accountActionsPopup orderOut:nil]; + [self orderOut:nil]; + OCC::Systray::instance()->setIsOpen(false); + OCC::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 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..e8865f455dd34 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,277 @@ 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(); - setIsOpen(true); + showQtTrayPopup(geometry(), position); + setIsOpen(true); +#endif +} + +void Systray::hideWindow() +{ + if (!isOpen()) { return; } + +#ifdef Q_OS_MACOS + hideMacOSTrayPopup(); +#else + hideQtTrayPopup(); #endif + setIsOpen(false); +} + +void Systray::showQMLWindow() +{ + showActivitiesWindow(); +} - if (!_trayWindow) { +void Systray::showActivitiesWindow(int userIndex) +{ + const auto userModel = UserModel::instance(); + if (!userModel) { return; } - if (position == WindowPosition::Center) { - positionWindowAtScreenCenter(_trayWindow.data()); - } else { - positionWindowAtTray(_trayWindow.data()); + 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; } - _trayWindow->show(); - _trayWindow->raise(); - _trayWindow->requestActivate(); - setIsOpen(true); - UserModel::instance()->fetchCurrentActivityModel(); -} + hideWindow(); -void Systray::hideWindow() -{ - if (!isOpen()) { + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open activities window as no tray engine was available"; return; } -#ifdef Q_OS_MACOS - if (!useNormalWindow()) { - hideMacOSTrayPopup(); - if (_trayWindow) { - _trayWindow->hide(); - } - setIsOpen(false); + 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; } -#else - if (!useNormalWindow()) { - if (_popupWindow) { - _popupWindow->hide(); - } - if (_trayWindow) { - _trayWindow->hide(); + + 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 defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 9, 0) + configureMacOSExpandedQuickWindow(window); #endif - if (!_trayWindow) { + 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() @@ -258,7 +477,7 @@ void Systray::setupContextMenu() if (AccountManager::instance()->accounts().isEmpty()) { _contextMenu->addAction(tr("Add account"), this, &Systray::openAccountWizard); } else { - _contextMenu->addAction(tr("Open %1 Desktop", "Open Nextcloud main window. Placeholer will be the application name. Please keep it.").arg(APPLICATION_NAME), this, [this]{ showWindow(); }); + _contextMenu->addAction(tr("Open %1 Desktop", "Open Nextcloud main window. Placeholer will be the application name. Please keep it.").arg(APPLICATION_NAME), this, [this]{ showActivitiesWindow(); }); } auto pauseAction = _contextMenu->addAction(tr("Pause sync"), this, &Systray::slotPauseAllFolders); @@ -611,14 +830,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 +930,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 +1039,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..7e9890a6b86b8 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -12,6 +12,7 @@ #include #include +#include #include class QScreen; @@ -90,6 +91,7 @@ class Systray : public QSystemTrayIcon [[nodiscard]] bool syncIsPaused() const; [[nodiscard]] bool anySyncFolders() const; [[nodiscard]] bool isOpen() const; + [[nodiscard]] bool isActivitySurfaceVisible() const; [[nodiscard]] bool enableAddAccount() const; @@ -145,8 +147,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 +217,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 +231,11 @@ private slots: QStringListModel _fakeActivityModel; }; +#ifndef Q_OS_MACOS +void showQtTrayPopup(const QRect &iconRect, Systray::WindowPosition position); +void hideQtTrayPopup(); +#endif + } // namespace OCC #endif //SYSTRAY_H diff --git a/src/gui/tray/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 875a2d16eb755..b9e4db7ff421a 100644 --- a/src/gui/tray/asyncimageresponse.cpp +++ b/src/gui/tray/asyncimageresponse.cpp @@ -14,6 +14,31 @@ using namespace Qt::StringLiterals; +namespace { + +QImage aspectFitImage(const QImage &sourceImage, const QSize &requestedSize) +{ + if (sourceImage.isNull() || !requestedSize.isValid()) { + return sourceImage; + } + + return sourceImage.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); +} + +QRectF aspectFitRect(const QSize &sourceSize, const QSize &targetSize) +{ + if (!sourceSize.isValid() || !targetSize.isValid()) { + return QRectF(QPointF(0.0, 0.0), targetSize); + } + + const auto scaledSize = sourceSize.scaled(targetSize, Qt::KeepAspectRatio); + return QRectF(QPointF((targetSize.width() - scaledSize.width()) / 2.0, + (targetSize.height() - scaledSize.height()) / 2.0), + scaledSize); +} + +} + AsyncImageResponse::AsyncImageResponse(const QString &id, const QSize &requestedSize) { if (id.isEmpty()) { @@ -122,7 +147,7 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) if (const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); !(mimetype.isValid() && mimetype.inherits("image/svg+xml"_L1))) { // Not an SVG: let's let QImage deal with the response. - setImageAndEmitFinished(QImage::fromData(imageData).scaled(_requestedImageSize)); + setImageAndEmitFinished(aspectFitImage(QImage::fromData(imageData), _requestedImageSize)); return; } @@ -136,7 +161,7 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32); scaledSvg.fill("transparent"); QPainter painterForSvg(&scaledSvg); - svgRenderer.render(&painterForSvg); + svgRenderer.render(&painterForSvg, aspectFitRect(svgRenderer.defaultSize(), _requestedImageSize)); if (!_svgRecolor.isValid()) { setImageAndEmitFinished(scaledSvg); @@ -150,4 +175,3 @@ void AsyncImageResponse::processNetworkReply(QNetworkReply *reply) imagePainter.drawImage(0, 0, scaledSvg); setImageAndEmitFinished(image); } - diff --git a/src/gui/tray/syncstatussummary.cpp b/src/gui/tray/syncstatussummary.cpp index 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..25963f9c6b64d --- /dev/null +++ b/src/gui/tray/trayaccountappsmodel.cpp @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "trayaccountappsmodel.h" + +#include "accountmanager.h" +#include "account.h" +#include "guiutility.h" + +namespace OCC { + +TrayAccountAppsModel *TrayAccountAppsModel::_instance = nullptr; + +TrayAccountAppsModel *TrayAccountAppsModel::instance() +{ + if (!_instance) { + _instance = new TrayAccountAppsModel(); + } + return _instance; +} + +TrayAccountAppsModel::TrayAccountAppsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +void TrayAccountAppsModel::setUserId(const int userId) +{ + const auto 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 {}; + } + + auto apps = AccountAppList{}; + const auto allApps = account->appList(); + const auto talkApp = account->findApp(QStringLiteral("spreed")); + const auto assistantEnabled = account->account()->capabilities().ncAssistantEnabled(); + for (const auto app : allApps) { + // Filter out Talk because we have a dedicated button for it. + if (talkApp && app->id() == talkApp->id() && !assistantEnabled) { + continue; + } + + apps << app; + } + + return apps; +} + +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/usermodel.cpp b/src/gui/tray/usermodel.cpp index b7ea769b2880f..ee217fc068fed 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() 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..4daca799c0714 --- /dev/null +++ b/src/gui/trayaccountpopup_qt.cpp @@ -0,0 +1,743 @@ +/* + * 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/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 + +namespace OCC { + +// Keep behavior and menu taxonomy aligned with src/gui/macOS/trayaccountpopup_mac.mm. + +namespace { + +QPointer s_trayPopup; +QHash s_remoteAppIconCache; + +QRectF aspectFitRect(const QSize &sourceSize, const QSize &targetSize) +{ + if (!sourceSize.isValid() || !targetSize.isValid()) { + return QRectF(QPointF(0.0, 0.0), targetSize); + } + + const auto scaledSize = sourceSize.scaled(targetSize, Qt::KeepAspectRatio); + return QRectF(QPointF((targetSize.width() - scaledSize.width()) / 2.0, + (targetSize.height() - scaledSize.height()) / 2.0), + scaledSize); +} + +QImage imageFromImageData(const QByteArray &imageData, const QSize &requestedSize) +{ + if (imageData.isEmpty()) { + return {}; + } + + const auto mimetype = QMimeDatabase().mimeTypeForData(imageData); + if (mimetype.isValid() && mimetype.inherits(QStringLiteral("image/svg+xml"))) { + auto renderer = QSvgRenderer{}; + if (!renderer.load(imageData)) { + return {}; + } + + auto image = QImage(requestedSize, QImage::Format_ARGB32); + image.fill(Qt::transparent); + auto painter = QPainter(&image); + renderer.render(&painter, aspectFitRect(renderer.defaultSize(), requestedSize)); + return image; + } + + auto image = QImage::fromData(imageData); + if (!image.isNull() && requestedSize.isValid()) { + image = image.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return image; +} + +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 templateThemeIcon(const QString &iconName, const QPalette &palette) +{ + return templateIconFromIcon(QIcon(QStringLiteral(":/client/theme/%1").arg(iconName)), QSize(18, 18), palette); +} + +QIcon templateBlackThemeIcon(const QString &iconName, const QPalette &palette) +{ + return templateIconFromIcon(QIcon(QStringLiteral(":/client/theme/black/%1").arg(iconName)), QSize(18, 18), palette); +} + +QString statusText(const UserStatus::OnlineStatus status) +{ + switch (status) { + 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 QPalette &iconPalette) +{ + if (!accountState || !accountState->account()) { + return {}; + } + return QStringLiteral("%1:%2:%3").arg( + accountState->account()->id(), + templateIconPaletteCacheKey(iconPalette), + url.toString()); +} + +void fetchRemoteAppIcon(QAction *action, + const AccountStatePtr &accountState, + const QUrl &iconUrl, + const QPalette &iconPalette) +{ + if (!action || !accountState || !accountState->account() || !isRemoteIconUrl(iconUrl)) { + return; + } + + const auto cacheKey = remoteIconCacheKey(accountState, iconUrl, 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, iconPalette](const QByteArray &iconData) { + const auto image = imageFromImageData(iconData, QSize(18, 18)); + 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 titleWithDetail(const QString &title, const QString &subtitle, const QString &dateTime) +{ + auto result = title; + if (!subtitle.isEmpty()) { + result = QStringLiteral("%1 - %2").arg(result, subtitle); + } + if (!dateTime.isEmpty()) { + result = QStringLiteral("%1 (%2)").arg(result, dateTime); + } + return result; +} + +QAction *addMenuAction(QMenu *menu, const QIcon &icon, const QString &text) +{ + return icon.isNull() ? menu->addAction(text) : menu->addAction(icon, text); +} + +QAction *addSectionHeading(QMenu *menu, const QString &text) +{ + auto action = new QWidgetAction(menu); + auto label = new QLabel(text, menu); + label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + label->setContentsMargins(10, 4, 10, 2); + label->setEnabled(false); + label->setFont(menu->font()); + action->setEnabled(false); + action->setDefaultWidget(label); + menu->addAction(action); + 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 openNotification(const int userId, const bool opensSettings) +{ + closeTrayPopup(); + if (opensSettings) { + Systray::instance()->openSettings(); + return; + } + Systray::instance()->showActivitiesWindow(userId); +} + +bool populateAppsMenu(QMenu *menu, const int userId) +{ + menu->clear(); + + const auto appsModel = TrayAccountAppsModel::instance(); + appsModel->setUserId(userId); + const auto appCount = appsModel->rowCount(); + const auto accounts = AccountManager::instance()->accounts(); + const auto accountState = userId >= 0 && userId < accounts.size() + ? accounts.at(userId) + : AccountStatePtr{}; + const auto appIconPalette = nativeMenuIconPalette(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 = blackThemeIcon(QStringLiteral("more-apps.svg")); + } + appIcon = templateIconFromIcon(appIcon, QSize(18, 18), appIconPalette); + + const auto action = addMenuAction(menu, appIcon, appName); + fetchRemoteAppIcon(action, accountState, appIconUrl, appIconPalette); + QObject::connect(action, &QAction::triggered, action, [appUrl] { + closeTrayPopup(); + TrayAccountAppsModel::instance()->openAppUrl(appUrl); + }); + } + + if (menu->isEmpty()) { + const auto noAppsAction = menu->addAction(QCoreApplication::translate("TrayAccountPopup", "No apps available")); + noAppsAction->setEnabled(false); + } + + return appCount > 0; +} + +void populateNotificationActionsMenu(QMenu *menu, const int userId, const int activityIndex, const QVariantList &actions) +{ + for (const auto &actionVariant : actions) { + const auto actionData = actionVariant.toMap(); + const auto title = actionData.value(QStringLiteral("label")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto actionType = actionData.value(QStringLiteral("actionType")).toString(); + const auto actionIndex = actionData.value(QStringLiteral("actionIndex")).toInt(); + const auto action = menu->addAction(title); + QObject::connect(action, &QAction::triggered, action, [userId, activityIndex, actionType, actionIndex] { + if (actionType == QStringLiteral("dismiss")) { + UserModel::instance()->dismissNotification(userId, activityIndex); + return; + } + if (actionType == QStringLiteral("openActivities")) { + openActivitiesForUser(userId); + return; + } + closeTrayPopup(); + UserModel::instance()->triggerNotificationAction(userId, activityIndex, actionIndex); + }); + } +} + +void addNotifications(QMenu *menu, const int userId, const QVariantList ¬ifications) +{ + if (notifications.isEmpty()) { + return; + } + + 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(); + const auto menuText = titleWithDetail(title, {}, dateTime); + if (actions.isEmpty()) { + const auto action = addMenuAction(menu, activityIcon(notificationData), menuText); + QObject::connect(action, &QAction::triggered, action, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + continue; + } + + const auto notificationMenu = menu->addMenu(activityIcon(notificationData), menuText); + const auto openAction = notificationMenu->addAction(QCoreApplication::translate("TrayAccountPopup", "Open")); + QObject::connect(openAction, &QAction::triggered, openAction, [userId, opensSettings] { + openNotification(userId, opensSettings); + }); + notificationMenu->addSeparator(); + populateNotificationActionsMenu(notificationMenu, userId, activityIndex, actions); + } + + 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 noRecentActivity = addMenuAction(menu, + templateBlackThemeIcon(QStringLiteral("activity.svg"), menuIconPalette), + QCoreApplication::translate("TrayAccountPopup", "No recent activity")); + noRecentActivity->setEnabled(false); + } + + for (const auto &recentActivityVariant : recentActivities) { + const auto activityData = recentActivityVariant.toMap(); + const auto title = activityData.value(QStringLiteral("title")).toString(); + if (title.isEmpty()) { + continue; + } + + const auto subtitle = activityData.value(QStringLiteral("subtitle")).toString(); + const auto dateTime = activityData.value(QStringLiteral("dateTime")).toString(); + const auto action = addMenuAction(menu, activityIcon(activityData), titleWithDetail(title, subtitle, dateTime)); + QObject::connect(action, &QAction::triggered, action, [userId] { + openActivitiesForUser(userId); + }); + } + + const auto moreActivitiesAction = menu->addAction(QCoreApplication::translate("TrayAccountPopup", "More activity\342\200\246")); + QObject::connect(moreActivitiesAction, &QAction::triggered, moreActivitiesAction, [userId] { + openActivitiesForUser(userId); + }); +} + +void populateAccountMenu(QMenu *menu, const int userId, const bool fetchActivityPreview = true) +{ + menu->clear(); + + 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 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, + templateThemeIcon(QStringLiteral("file-open.svg"), menuIconPalette), + QCoreApplication::translate("TrayFoldersMenuButton", "Open local folder")); + QObject::connect(openFolderAction, &QAction::triggered, openFolderAction, [userId] { + openLocalFolderForUser(userId); + }); + + const auto assistantEnabled = userModel->data(userModelIndex, UserModel::AssistantEnabledRole).toBool(); + if (assistantEnabled) { + const auto assistantAction = addMenuAction(menu, + templateBlackThemeIcon(QStringLiteral("nc-assistant-app.svg"), menuIconPalette), + QCoreApplication::translate("MainWindow", "Ask Assistant\302\240\342\200\246")); + QObject::connect(assistantAction, &QAction::triggered, assistantAction, [userId] { + openAssistantForUser(userId); + }); + } + + const auto appsMenu = menu->addMenu(templateBlackThemeIcon(QStringLiteral("more-apps.svg"), menuIconPalette), + QCoreApplication::translate("TrayWindowHeader", "More apps")); + appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); + QObject::connect(appsMenu, &QMenu::aboutToShow, appsMenu, [appsMenu, userId] { + appsMenu->menuAction()->setEnabled(populateAppsMenu(appsMenu, userId)); + }); + + menu->addSeparator(); + + addNotifications(menu, userId, userModel->data(userModelIndex, UserModel::TrayNotificationsRole).toList()); + addRecentActivities(menu, userId, userModel->data(userModelIndex, UserModel::RecentActivitiesRole).toList()); +} + +void populateTrayMenu(QMenu *menu) +{ + menu->clear(); + + const auto userModel = UserModel::instance(); + const auto menuIconPalette = nativeMenuIconPalette(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 = menu->addMenu(accountIcon, accountText); + QObject::connect(accountMenu, &QMenu::aboutToShow, accountMenu, [accountMenu, userId] { + populateAccountMenu(accountMenu, userId); + }); + QObject::connect(userModel, + &QAbstractItemModel::dataChanged, + accountMenu, + [accountMenu, userId](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { + if (!accountMenu->isVisible() || userId < topLeft.row() || userId > bottomRight.row()) { + return; + } + if (!roles.isEmpty() + && !roles.contains(UserModel::RecentActivitiesRole) + && !roles.contains(UserModel::TrayNotificationsRole)) { + return; + } + populateAccountMenu(accountMenu, userId, false); + }); + } + menu->addSeparator(); + } + + if (Systray::instance()->enableAddAccount()) { + const auto addAccountAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("add.svg"), menuIconPalette), + Systray::tr("Add account")); + QObject::connect(addAccountAction, &QAction::triggered, addAccountAction, [] { + closeTrayPopup(); + Systray::instance()->openAccountWizard(); + }); + } + + const auto settingsAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("settings.svg"), menuIconPalette), + Systray::tr("Settings")); + QObject::connect(settingsAction, &QAction::triggered, settingsAction, [] { + closeTrayPopup(); + Systray::instance()->openSettings(); + }); + + const auto quitAction = addMenuAction(menu, + templateThemeIcon(QStringLiteral("close.svg"), 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); + } + + auto positionPoint = QCursor::pos(); + if (iconRect.isValid() && !iconRect.isNull()) { + const auto trayCenter = iconRect.center(); + positionPoint.setX(trayCenter.x() - menuSize.width() / 2); + if (trayCenter.y() < availableGeometry.center().y()) { + positionPoint.setY(availableGeometry.top()); + } else { + positionPoint.setY(availableGeometry.bottom() - menuSize.height() + 1); + } + } + + return clampedMenuPosition(positionPoint, menuSize, availableGeometry); +} + +} // namespace + +void showQtTrayPopup(const QRect &iconRect, const Systray::WindowPosition position) +{ + hideQtTrayPopup(); + + const auto menu = new QMenu(); + s_trayPopup = menu; + populateTrayMenu(menu); + + QObject::connect(menu, &QMenu::aboutToHide, menu, [menu] { + if (s_trayPopup == menu) { + s_trayPopup = nullptr; + } + Systray::instance()->setIsOpen(false); + menu->deleteLater(); + }); + + menu->popup(trayPopupPosition(menu, iconRect, position)); +} + +void hideQtTrayPopup() +{ + if (s_trayPopup) { + s_trayPopup->close(); + } +} + +} // namespace OCC diff --git a/src/gui/userstatusselectormodel.cpp b/src/gui/userstatusselectormodel.cpp index 2a1d3b8c937c4..9c3cda3577cab 100644 --- a/src/gui/userstatusselectormodel.cpp +++ b/src/gui/userstatusselectormodel.cpp @@ -98,6 +98,7 @@ void UserStatusSelectorModel::reset() &UserStatusSelectorModel::onMessageCleared); } _userStatusConnector = nullptr; + _setUserStatusOperations.clear(); } void UserStatusSelectorModel::init() @@ -126,6 +127,18 @@ void UserStatusSelectorModel::init() void UserStatusSelectorModel::onUserStatusSet() { + auto operation = SetUserStatusOperation::Message; + if (!_setUserStatusOperations.empty()) { + operation = _setUserStatusOperations.front(); + _setUserStatusOperations.pop_front(); + } else if (!_finishOnOnlineStatusSet) { + return; + } + + if (operation == SetUserStatusOperation::OnlineStatus && !_finishOnOnlineStatusSet) { + return; + } + emit finished(); } @@ -156,6 +169,9 @@ void UserStatusSelectorModel::onError(UserStatusConnector::Error error) return; case UserStatusConnector::Error::CouldNotSetUserStatus: + if (!_setUserStatusOperations.empty()) { + _setUserStatusOperations.pop_front(); + } setError(tr("Could not set status. Make sure you are connected to the server.")); return; @@ -178,6 +194,21 @@ void UserStatusSelectorModel::clearError() setError(""); } +bool UserStatusSelectorModel::finishOnOnlineStatusSet() const +{ + return _finishOnOnlineStatusSet; +} + +void UserStatusSelectorModel::setFinishOnOnlineStatusSet(bool finishOnOnlineStatusSet) +{ + if (_finishOnOnlineStatusSet == finishOnOnlineStatusSet) { + return; + } + + _finishOnOnlineStatusSet = finishOnOnlineStatusSet; + emit finishOnOnlineStatusSetChanged(); +} + void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) { if (!_userStatusConnector || status == _userStatus.state()) { @@ -185,6 +216,7 @@ void UserStatusSelectorModel::setOnlineStatus(UserStatus::OnlineStatus status) } _userStatus.setState(status); + _setUserStatusOperations.push_back(SetUserStatusOperation::OnlineStatus); _userStatusConnector->setUserStatus(_userStatus); emit userStatusChanged(); } @@ -307,6 +339,7 @@ void UserStatusSelectorModel::setUserStatus() } clearError(); + _setUserStatusOperations.push_back(SetUserStatusOperation::Message); _userStatusConnector->setUserStatus(_userStatus); } diff --git a/src/gui/userstatusselectormodel.h b/src/gui/userstatusselectormodel.h index 424925f555b12..ae5f8cc511819 100644 --- a/src/gui/userstatusselectormodel.h +++ b/src/gui/userstatusselectormodel.h @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -34,6 +35,7 @@ class UserStatusSelectorModel : public QObject Q_PROPERTY(QString clearAtDisplayString READ clearAtDisplayString NOTIFY clearAtDisplayStringChanged) Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) Q_PROPERTY(bool busyStatusSupported READ busyStatusSupported NOTIFY busyStatusSupportedChanged) + Q_PROPERTY(bool finishOnOnlineStatusSet READ finishOnOnlineStatusSet WRITE setFinishOnOnlineStatusSet NOTIFY finishOnOnlineStatusSetChanged) Q_PROPERTY(QUrl onlineIcon READ onlineIcon CONSTANT) Q_PROPERTY(QUrl awayIcon READ awayIcon CONSTANT) Q_PROPERTY(QUrl dndIcon READ dndIcon CONSTANT) @@ -91,9 +93,11 @@ class UserStatusSelectorModel : public QObject [[nodiscard]] QString errorMessage() const; [[nodiscard]] bool busyStatusSupported() const; + [[nodiscard]] bool finishOnOnlineStatusSet() const; public slots: void setUserIndex(const int userIndex); + void setFinishOnOnlineStatusSet(bool finishOnOnlineStatusSet); void setUserStatus(); void clearUserStatus(); void setClearAt(const OCC::UserStatusSelectorModel::ClearStageType clearStageType); @@ -106,9 +110,15 @@ public slots: void userStatusChanged(); void clearAtDisplayStringChanged(); void predefinedStatusesChanged(); + void finishOnOnlineStatusSetChanged(); void finished(); private: + enum class SetUserStatusOperation { + OnlineStatus, + Message, + }; + void init(); void reset(); void onUserStatusFetched(const UserStatus &userStatus); @@ -125,10 +135,12 @@ public slots: void clearError(); int _userIndex = -1; + bool _finishOnOnlineStatusSet = true; std::shared_ptr _userStatusConnector {}; QVector _predefinedStatuses; UserStatus _userStatus; std::unique_ptr _dateTimeProvider; + std::deque _setUserStatusOperations; QString _errorMessage; diff --git a/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/test/testsetuserstatusdialog.cpp b/test/testsetuserstatusdialog.cpp index 92512fa676cd7..7425fef3916d8 100644 --- a/test/testsetuserstatusdialog.cpp +++ b/test/testsetuserstatusdialog.cpp @@ -44,6 +44,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector void setUserStatus(const OCC::UserStatus &userStatus) override { + ++_setUserStatusCallCount; if (_couldNotSetUserStatusMessage) { emit error(Error::CouldNotSetUserStatus); return; @@ -59,6 +60,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector emit error(Error::CouldNotClearMessage); } else { _isMessageCleared = true; + emit UserStatusConnector::messageCleared(); } } @@ -84,9 +86,15 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector } [[nodiscard]] OCC::UserStatus userStatusSetByCallerOfSetUserStatus() const { return _userStatusSetByCallerOfSetUserStatus; } + [[nodiscard]] int setUserStatusCallCount() const { return _setUserStatusCallCount; } [[nodiscard]] bool messageCleared() const { return _isMessageCleared; } + void emitUserStatusSet() + { + emit UserStatusConnector::userStatusSet(); + } + void setErrorCouldNotFetchPredefinedUserStatuses(bool value) { _couldNotFetchPredefinedUserStatuses = value; @@ -121,6 +129,7 @@ class FakeUserStatusConnector : public OCC::UserStatusConnector OCC::UserStatus _userStatusSetByCallerOfSetUserStatus; OCC::UserStatus _userStatus; QVector _predefinedStatuses; + int _setUserStatusCallCount = 0; bool _isMessageCleared = false; bool _couldNotFetchPredefinedUserStatuses = false; bool _couldNotFetchUserStatus = false; @@ -277,6 +286,87 @@ private slots: QCOMPARE(userStatusChangedSpy.count(), 1); } + void testSetOnlineStatus_defaultEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(finishedSpy.count(), 1); + } + + void testSetOnlineStatus_finishOnOnlineStatusSetFalseDoesNotEmitFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(fakeUserStatusJob->userStatusSetByCallerOfSetUserStatus().state(), + OCC::UserStatus::OnlineStatus::Away); + QCOMPARE(finishedSpy.count(), 0); + } + + void testSetOnlineStatus_finishOnOnlineStatusSetFalseIgnoresExtraFinishedSignal() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + fakeUserStatusJob->emitUserStatusSet(); + + QCOMPARE(finishedSpy.count(), 0); + } + + void testSetUserStatus_finishOnOnlineStatusSetFalseStillEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setUserStatusMessage(QStringLiteral("Some status")); + model.setUserStatus(); + + QCOMPARE(finishedSpy.count(), 1); + } + + void testClearUserStatus_finishOnOnlineStatusSetFalseStillEmitsFinished() + { + auto fakeUserStatusJob = std::make_shared(); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.clearUserStatus(); + + QVERIFY(fakeUserStatusJob->messageCleared()); + QCOMPARE(finishedSpy.count(), 1); + } + + void testSetOnlineStatus_errorDoesNotEmitFinished() + { + auto fakeUserStatusJob = std::make_shared(); + fakeUserStatusJob->setErrorCouldNotSetUserStatusMessage(true); + OCC::UserStatusSelectorModel model(fakeUserStatusJob); + model.setFinishOnOnlineStatusSet(false); + QSignalSpy finishedSpy(&model, &OCC::UserStatusSelectorModel::finished); + + model.setOnlineStatus(OCC::UserStatus::OnlineStatus::Away); + + QCOMPARE(fakeUserStatusJob->setUserStatusCallCount(), 1); + QCOMPARE(model.errorMessage(), QStringLiteral("Could not set status. Make sure you are connected to the server.")); + QCOMPARE(finishedSpy.count(), 0); + } + void testSetUserStatus_setCustomMessage_userStatusSetCorrect() { auto fakeUserStatusJob = std::make_shared(); 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