Просмотр исходного кода

Group folder visibility improvements. Show dropdown in tray window.

Signed-off-by: alex-z <blackslayer4@gmail.com>
alex-z 2 лет назад
Родитель
Сommit
3be820d9a3

+ 3 - 0
resources.qrc

@@ -52,5 +52,8 @@
         <file>theme/Style/Style.qml</file>
         <file>theme/Style/Style.qml</file>
         <file>theme/Style/qmldir</file>
         <file>theme/Style/qmldir</file>
         <file>src/gui/filedetails/NCRadioButton.qml</file>
         <file>src/gui/filedetails/NCRadioButton.qml</file>
+        <file>src/gui/tray/ListItemLineAndSubline.qml</file>
+        <file>src/gui/tray/TrayFoldersMenuButton.qml</file>
+        <file>src/gui/tray/TrayFolderListItem.qml</file>
     </qresource>
     </qresource>
 </RCC>
 </RCC>

+ 52 - 0
src/gui/tray/ListItemLineAndSubline.qml

@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2023 by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+
+import Style 1.0
+
+ColumnLayout {
+    id: root
+
+    spacing: Style.standardSpacing
+
+    property string lineText: ""
+    property string sublineText: ""
+
+    property int titleFontSize: Style.unifiedSearchResultTitleFontSize
+    property int sublineFontSize: Style.unifiedSearchResultSublineFontSize
+
+    property color titleColor: Style.ncTextColor
+    property color sublineColor: Style.ncSecondaryTextColor
+
+    EnforcedPlainTextLabel {
+        id: title
+        Layout.fillWidth: true
+        text: root.lineText
+        elide: Text.ElideRight
+        font.pixelSize: root.titleFontSize
+        color: root.titleColor
+    }
+    EnforcedPlainTextLabel {
+        id: subline
+        Layout.fillWidth: true
+        text: root.sublineText
+        visible: text !== ""
+        elide: Text.ElideRight
+        font.pixelSize: root.sublineFontSize
+        color: root.sublineColor
+    }
+}

+ 70 - 0
src/gui/tray/TrayFolderListItem.qml

@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2023 by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+import QtQml 2.15
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import Style 1.0
+
+MenuItem {
+    id: root
+
+    property string subline: ""
+    property string iconSource: "image://svgimage-custom-color/folder-group.svg/" + Style.ncTextColor
+    property string toolTipText: root.text
+
+    NCToolTip {
+        visible: root.hovered && root.toolTipText !== ""
+        text: root.toolTipText
+    }
+
+    background: Item {
+        height: parent.height
+        width: parent.width
+        Rectangle {
+            anchors.fill: parent
+            anchors.margins: Style.normalBorderWidth
+            color: parent.parent.hovered || parent.parent.visualFocus ? Style.lightHover : "transparent"
+        }
+    }
+
+    contentItem: RowLayout {
+        anchors.fill: parent
+        anchors.leftMargin: Style.trayWindowMenuEntriesMargin
+        anchors.rightMargin: Style.trayWindowMenuEntriesMargin
+        spacing: Style.trayHorizontalMargin
+
+        Image {
+            source: root.iconSource
+            cache: true
+            sourceSize.width: root.height * Style.smallIconScaleFactor
+            sourceSize.height: root.height * Style.smallIconScaleFactor
+            verticalAlignment: Qt.AlignVCenter
+            horizontalAlignment: Qt.AlignHCenter
+
+            Layout.preferredHeight: root.height * Style.smallIconScaleFactor
+            Layout.preferredWidth: root.height * Style.smallIconScaleFactor
+            Layout.alignment: Qt.AlignVCenter
+        }
+
+        ListItemLineAndSubline {
+            lineText: root.text
+            sublineText: root.subline
+
+            spacing: Style.extraSmallSpacing
+
+            Layout.fillWidth: true
+        }
+    }
+}

+ 216 - 0
src/gui/tray/TrayFoldersMenuButton.qml

@@ -0,0 +1,216 @@
+/*
+ * Copyright (C) 2023 by Oleksandr Zolotov <alex@nextcloud.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+ * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * for more details.
+ */
+import QtQuick 2.15
+import QtQuick.Controls 2.15
+import QtQuick.Layouts 1.15
+import QtGraphicalEffects 1.0
+import Style 1.0
+
+HeaderButton {
+    id: root
+
+    signal folderEntryTriggered(string fullFolderPath, bool isGroupFolder)
+
+    required property var currentUser
+    property bool userHasGroupFolders: currentUser.groupFolders.length > 0
+
+    function openMenu() {
+        foldersMenuLoader.openMenu()
+    }
+
+    function closeMenu() {
+        foldersMenuLoader.closeMenu()
+    }
+
+    function toggleMenuOpen() {
+        if (foldersMenuLoader.isMenuVisible) {
+            closeMenu()
+        } else {
+            openMenu()
+        }
+    }
+
+    visible: currentUser.hasLocalFolder
+    display: AbstractButton.IconOnly
+    flat: true
+    palette: Style.systemPalette
+
+    Accessible.role: root.userHasGroupFolders ? Accessible.ButtonMenu : Accessible.Button
+    Accessible.name: tooltip.text
+    Accessible.onPressAction: root.clicked()
+
+    NCToolTip {
+        id: tooltip
+        visible: root.hovered && !foldersMenuLoader.isMenuVisible
+        text: root.userHasGroupFolders ? qsTr("Open local or group folders") : qsTr("Open local folder")
+    }
+
+    Image {
+        id: folderStateIndicator
+        visible: root.currentUser.hasLocalFolder
+        source: root.currentUser.isConnected ? Style.stateOnlineImageSource : Style.stateOfflineImageSource
+        cache: false
+
+        anchors.top: root.verticalCenter
+        anchors.left: root.horizontalCenter
+        sourceSize.width: Style.folderStateIndicatorSize
+        sourceSize.height: Style.folderStateIndicatorSize
+
+        Accessible.role: Accessible.Indicator
+        Accessible.name: root.currentUser.isConnected ? qsTr("Connected") : qsTr("Disconnected")
+        z: 1
+
+        Rectangle {
+            id: folderStateIndicatorBackground
+            width: Style.folderStateIndicatorSize + Style.trayFolderStatusIndicatorSizeOffset
+            height: width
+            anchors.centerIn: parent
+            color: Style.currentUserHeaderColor
+            radius: width * Style.trayFolderStatusIndicatorRadiusFactor
+            z: -2
+        }
+
+        Rectangle {
+            id: folderStateIndicatorBackgroundMouseHover
+            width: Style.folderStateIndicatorSize + Style.trayFolderStatusIndicatorSizeOffset
+            height: width
+            anchors.centerIn: parent
+            color: root.hovered ? Style.currentUserHeaderTextColor : "transparent"
+            opacity: Style.trayFolderStatusIndicatorMouseHoverOpacityFactor
+            radius: width * Style.trayFolderStatusIndicatorRadiusFactor
+            z: -1
+        }
+    }
+
+    RowLayout {
+        id: openLocalFolderButtonRowLayout
+
+        anchors.fill: parent
+        spacing: 0
+
+        Image {
+            id: openLocalFolderButtonIcon
+            cache: false
+            source: "qrc:///client/theme/white/folder.svg"
+
+            verticalAlignment: Qt.AlignCenter
+
+            Accessible.role: Accessible.Graphic
+            Accessible.name: qsTr("Group folder button")
+            Layout.leftMargin: Style.trayHorizontalMargin
+        }
+
+        Loader {
+            id: openLocalFolderButtonCaretIconLoader
+
+            active: root.userHasGroupFolders
+            visible: active
+
+            sourceComponent: ColorOverlay {
+                width: source.width
+                height: source.height
+                cached: true
+                color: Style.currentUserHeaderTextColor
+                source: Image {
+                    source: "qrc:///client/theme/white/caret-down.svg"
+                    sourceSize.width: Style.accountDropDownCaretSize
+                    sourceSize.height: Style.accountDropDownCaretSize
+
+                    verticalAlignment: Qt.AlignCenter
+
+                    Layout.alignment: Qt.AlignRight
+                    Layout.margins: Style.accountDropDownCaretMargin
+                }
+            }
+        }
+    }
+
+    Loader {
+        id: foldersMenuLoader
+
+        property var openMenu: function(){}
+        property var closeMenu: function(){}
+        property bool isMenuVisible: false
+
+        anchors.fill: parent
+        active: root.userHasGroupFolders
+        visible: active
+
+        sourceComponent: AutoSizingMenu {
+            id: foldersMenu
+
+            x: Style.trayWindowMenuOffsetX
+            y: (root.y + root.height + Style.trayWindowMenuOffsetY)
+            width: Style.trayWindowWidth * Style.trayWindowMenuWidthFactor
+            height: implicitHeight + y > Style.trayWindowHeight ? Style.trayWindowHeight - y : implicitHeight
+            closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape
+
+            contentItem: ScrollView {
+                id: foldersMenuScrollView
+
+                ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+
+                data: WheelHandler {
+                    target: foldersMenuScrollView.contentItem
+                }
+
+                ListView {
+                    id: foldersMenuListView
+
+                    implicitHeight: contentHeight
+                    model: root.currentUser.groupFolders
+                    interactive: true
+                    clip: true
+                    currentIndex: foldersMenu.currentIndex
+                    anchors.left: parent.left
+                    anchors.right: parent.right
+
+                    delegate: TrayFolderListItem {
+                        id: groupFoldersEntry
+
+                        property bool isGroupFolder: model.modelData.isGroupFolder
+
+                        text: model.modelData.name
+                        toolTipText: !isGroupFolder ? qsTr("Open local folder \"%1\"").arg(model.modelData.fullPath) : qsTr("Open groupfolder \"%1\"").arg(model.modelData.fullPath)
+                        subline: model.modelData.parentPath
+                        width: foldersMenuListView.width
+                        height: Style.standardPrimaryButtonHeight
+                        iconSource: !isGroupFolder ? "image://svgimage-custom-color/folder.svg/" + Style.ncTextColor : "image://svgimage-custom-color/folder-group.svg/" + Style.ncTextColor
+
+                        onTriggered: {
+                            foldersMenu.close();
+                            root.folderEntryTriggered(model.modelData.fullPath, isGroupFolder);
+                        }
+
+                        Accessible.role: Accessible.MenuItem
+                        Accessible.name: qsTr("Open %1 in file explorer").arg(title)
+                        Accessible.onPressAction: groupFoldersEntry.triggered()
+                    }
+
+                    Accessible.role: Accessible.PopupMenu
+                    Accessible.name: qsTr("User group and local folders menu")
+                }
+            }
+
+            Component.onCompleted: {
+                foldersMenuLoader.openMenu = open
+                foldersMenuLoader.closeMenu = close
+            }
+
+            Connections {
+                onVisibleChanged: foldersMenuLoader.isMenuVisible = visible
+            }
+        }
+    }
+}

+ 6 - 18
src/gui/tray/UnifiedSearchResultItem.qml

@@ -24,6 +24,7 @@ RowLayout {
     property color titleColor: Style.ncTextColor
     property color titleColor: Style.ncTextColor
     property color sublineColor: Style.ncSecondaryTextColor
     property color sublineColor: Style.ncSecondaryTextColor
 
 
+
     Accessible.role: Accessible.ListItem
     Accessible.role: Accessible.ListItem
     Accessible.name: resultTitle
     Accessible.name: resultTitle
     Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
     Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
@@ -79,29 +80,16 @@ RowLayout {
         }
         }
     }
     }
 
 
-    ColumnLayout {
+    ListItemLineAndSubline {
         id: unifiedSearchResultTextContainer
         id: unifiedSearchResultTextContainer
 
 
+        spacing: Style.standardSpacing
+
         Layout.fillWidth: true
         Layout.fillWidth: true
         Layout.rightMargin: Style.trayHorizontalMargin
         Layout.rightMargin: Style.trayHorizontalMargin
-        spacing: Style.standardSpacing
 
 
-        EnforcedPlainTextLabel {
-            id: unifiedSearchResultTitleText
-            Layout.fillWidth: true
-            text: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ")
-            elide: Text.ElideRight
-            font.pixelSize: unifiedSearchResultItemDetails.titleFontSize
-            color: unifiedSearchResultItemDetails.titleColor
-        }
-        EnforcedPlainTextLabel {
-            id: unifiedSearchResultTextSubline
-            Layout.fillWidth: true
-            text: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ")
-            elide: Text.ElideRight
-            font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize
-            color: unifiedSearchResultItemDetails.sublineColor
-        }
+        lineText: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ")
+        sublineText: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ")
     }
     }
 
 
 }
 }

+ 17 - 60
src/gui/tray/Window.qml

@@ -83,6 +83,7 @@ ApplicationWindow {
             if(Systray.isOpen) {
             if(Systray.isOpen) {
                 accountMenu.close();
                 accountMenu.close();
                 appsMenu.close();
                 appsMenu.close();
+                openLocalFolderButton.closeMenu()
             }
             }
         }
         }
 
 
@@ -466,25 +467,25 @@ ApplicationWindow {
                                 id: currentAccountStatusIndicatorBackground
                                 id: currentAccountStatusIndicatorBackground
                                 visible: UserModel.currentUser.isConnected
                                 visible: UserModel.currentUser.isConnected
                                          && UserModel.currentUser.serverHasUserStatus
                                          && UserModel.currentUser.serverHasUserStatus
-                                width: Style.accountAvatarStateIndicatorSize + 2
+                                width: Style.accountAvatarStateIndicatorSize +  + Style.trayFolderStatusIndicatorSizeOffset
                                 height: width
                                 height: width
                                 anchors.bottom: currentAccountAvatar.bottom
                                 anchors.bottom: currentAccountAvatar.bottom
                                 anchors.right: currentAccountAvatar.right
                                 anchors.right: currentAccountAvatar.right
                                 color: Style.currentUserHeaderColor
                                 color: Style.currentUserHeaderColor
-                                radius: width*0.5
+                                radius: width * Style.trayFolderStatusIndicatorRadiusFactor
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
                                 id: currentAccountStatusIndicatorMouseHover
                                 id: currentAccountStatusIndicatorMouseHover
                                 visible: UserModel.currentUser.isConnected
                                 visible: UserModel.currentUser.isConnected
                                          && UserModel.currentUser.serverHasUserStatus
                                          && UserModel.currentUser.serverHasUserStatus
-                                width: Style.accountAvatarStateIndicatorSize + 2
+                                width: Style.accountAvatarStateIndicatorSize +  + Style.trayFolderStatusIndicatorSizeOffset
                                 height: width
                                 height: width
                                 anchors.bottom: currentAccountAvatar.bottom
                                 anchors.bottom: currentAccountAvatar.bottom
                                 anchors.right: currentAccountAvatar.right
                                 anchors.right: currentAccountAvatar.right
                                 color: currentAccountButton.hovered ? Style.currentUserHeaderTextColor : "transparent"
                                 color: currentAccountButton.hovered ? Style.currentUserHeaderTextColor : "transparent"
-                                opacity: 0.2
-                                radius: width*0.5
+                                opacity: Style.trayFolderStatusIndicatorMouseHoverOpacityFactor
+                                radius: width * Style.trayFolderStatusIndicatorRadiusFactor
                             }
                             }
 
 
                             Image {
                             Image {
@@ -586,62 +587,18 @@ ApplicationWindow {
                     Layout.fillWidth: true
                     Layout.fillWidth: true
                 }
                 }
 
 
-                RowLayout {
-                    id: openLocalFolderRowLayout
-                    spacing: 0
-                    Layout.preferredWidth:  Style.trayWindowHeaderHeight
-                    Layout.preferredHeight: Style.trayWindowHeaderHeight
-                    Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
-
-                    Accessible.role: Accessible.Button
-                    Accessible.name: qsTr("Open local folder of current account")
-
-                    HeaderButton {
-                        id: openLocalFolderButton
-                        visible: UserModel.currentUser.hasLocalFolder
-                        icon.source: "qrc:///client/theme/white/folder.svg"
-                        icon.color: Style.currentUserHeaderTextColor
-                        onClicked: UserModel.openCurrentAccountLocalFolder()
+                TrayFoldersMenuButton {
+                    id: openLocalFolderButton
 
 
-                        Image {
-                            id: folderStateIndicator
-                            visible: UserModel.currentUser.hasLocalFolder
-                            source: UserModel.currentUser.isConnected
-                                    ? Style.stateOnlineImageSource
-                                    : Style.stateOfflineImageSource
-                            cache: false
+                    visible: currentUser.hasLocalFolder
+                    currentUser: UserModel.currentUser
 
 
-                            anchors.top: openLocalFolderButton.verticalCenter
-                            anchors.left: openLocalFolderButton.horizontalCenter
-                            sourceSize.width: Style.folderStateIndicatorSize
-                            sourceSize.height: Style.folderStateIndicatorSize
+                    Layout.preferredWidth:  Style.iconButtonWidth * Style.trayFolderListButtonWidthScaleFactor
+                    Layout.alignment: Qt.AlignHCenter
 
 
-                            Accessible.role: Accessible.Indicator
-                            Accessible.name: UserModel.currentUser.isConnected ? qsTr("Connected") : qsTr("Disconnected")
-                            z: 1
-
-                            Rectangle {
-                                id: folderStateIndicatorBackground
-                                width: Style.folderStateIndicatorSize + 2
-                                height: width
-                                anchors.centerIn: parent
-                                color: Style.currentUserHeaderColor
-                                radius: width*0.5
-                                z: -2
-                            }
+                    onClicked: openLocalFolderButton.userHasGroupFolders ? openLocalFolderButton.toggleMenuOpen() : UserModel.openCurrentAccountLocalFolder()
 
 
-                            Rectangle {
-                                id: folderStateIndicatorBackgroundMouseHover
-                                width: Style.folderStateIndicatorSize + 2
-                                height: width
-                                anchors.centerIn: parent
-                                color: openLocalFolderButton.hovered ? Style.currentUserHeaderTextColor : "transparent"
-                                opacity: 0.2
-                                radius: width*0.5
-                                z: -1
-                            }
-                        }
-                    }
+                    onFolderEntryTriggered: isGroupFolder ? UserModel.openCurrentAccountFolderFromTrayInfo(fullFolderPath) : UserModel.openCurrentAccountLocalFolder()
                 }
                 }
 
 
                 HeaderButton {
                 HeaderButton {
@@ -678,9 +635,9 @@ ApplicationWindow {
 
 
                     Menu {
                     Menu {
                         id: appsMenu
                         id: appsMenu
-                        x: -2
-                        y: (trayWindowAppsButton.y + trayWindowAppsButton.height + 2)
-                        width: Style.trayWindowWidth * 0.35
+                        x: Style.trayWindowMenuOffsetX
+                        y: (trayWindowAppsButton.y + trayWindowAppsButton.height + Style.trayWindowMenuOffsetY)
+                        width: Style.trayWindowWidth * Style.trayWindowMenuWidthFactor
                         height: implicitHeight + y > Style.trayWindowHeight ? Style.trayWindowHeight - y : implicitHeight
                         height: implicitHeight + y > Style.trayWindowHeight ? Style.trayWindowHeight - y : implicitHeight
                         closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape
                         closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape
 
 

+ 188 - 1
src/gui/tray/usermodel.cpp

@@ -36,6 +36,19 @@ constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10;
 }
 }
 
 
 namespace OCC {
 namespace OCC {
+    
+TrayFolderInfo::TrayFolderInfo(const QString &name, const QString &parentPath, const QString &fullPath, FolderType folderType)
+    : _name(name)
+    , _parentPath(parentPath)
+    , _fullPath(fullPath)
+    , _folderType(folderType)
+{
+}
+
+bool TrayFolderInfo::isGroupFolder() const
+{
+    return _folderType == GroupFolder;
+}
 
 
 User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent)
 User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent)
     : QObject(parent)
     : QObject(parent)
@@ -76,6 +89,8 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent)
     connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::headerTextColorChanged);
     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::accentColorChanged);
 
 
+    connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders);
+
     connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest);
     connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest);
     
     
     connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage);
     connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage);
@@ -299,6 +314,36 @@ void User::slotCheckExpiredActivities()
     }
     }
 }
 }
 
 
+void User::parseNewGroupFolderPath(const QString &mountPoint)
+{
+    if (mountPoint.isEmpty()) {
+        return;
+    }
+    auto mountPointSplit = mountPoint.split(QLatin1Char('/'), Qt::SkipEmptyParts);
+
+    if (mountPointSplit.isEmpty()) {
+        return;
+    }
+
+    const auto groupFolderName = mountPointSplit.takeLast();
+    const auto parentPath = mountPointSplit.join(QLatin1Char('/'));
+    _trayFolderInfos.push_back(QVariant::fromValue(TrayFolderInfo{groupFolderName, parentPath, mountPoint, TrayFolderInfo::GroupFolder}));
+}
+
+void User::prePendGroupFoldersWithLocalFolder()
+{
+    if (!_trayFolderInfos.isEmpty() && !_trayFolderInfos.first().value<TrayFolderInfo>().isGroupFolder()) {
+        return;
+    }
+    const auto localFolderName = getFolder()->shortGuiLocalPath();
+    auto localFolderPathSplit = getFolder()->path().split(QLatin1Char('/'), Qt::SkipEmptyParts);
+    if (!localFolderPathSplit.isEmpty()) {
+        localFolderPathSplit.removeLast();
+    }
+    const auto localFolderParentPath = !localFolderPathSplit.isEmpty() ? localFolderPathSplit.join(QLatin1Char('/')) : "/";
+    _trayFolderInfos.push_front(QVariant::fromValue(TrayFolderInfo{localFolderName, localFolderParentPath, getFolder()->path(), TrayFolderInfo::Folder}));
+}
+
 void User::connectPushNotifications() const
 void User::connectPushNotifications() const
 {
 {
     connect(_account->account().data(), &Account::pushNotificationsDisabled, this, &User::slotDisconnectPushNotifications, Qt::UniqueConnection);
     connect(_account->account().data(), &Account::pushNotificationsDisabled, this, &User::slotDisconnectPushNotifications, Qt::UniqueConnection);
@@ -747,6 +792,11 @@ void User::processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr
     }
     }
 }
 }
 
 
+const QVariantList &User::groupFolders() const
+{
+    return _trayFolderInfos;
+}
+
 void User::slotItemCompleted(const QString &folder, const SyncFileItemPtr &item)
 void User::slotItemCompleted(const QString &folder, const SyncFileItemPtr &item)
 {
 {
     auto folderInstance = FolderMan::instance()->folder(folder);
     auto folderInstance = FolderMan::instance()->folder(folder);
@@ -804,6 +854,42 @@ void User::openLocalFolder()
     }
     }
 }
 }
 
 
+void User::openFolderLocallyOrInBrowser(const QString &fullRemotePath)
+{
+    const auto folder = getFolder();
+
+    if (!folder) {
+        return;
+    }
+
+    // remove remote path prefix and leading slash
+    auto fullRemotePathToPathInDb = folder->remotePath() != QStringLiteral("/") ? fullRemotePath.mid(folder->remotePathTrailingSlash().size()) : fullRemotePath;
+    if (fullRemotePathToPathInDb.startsWith("/")) {
+        fullRemotePathToPathInDb = fullRemotePathToPathInDb.mid(1);
+    }
+
+    SyncJournalFileRecord rec;
+    if (folder->journalDb()->getFileRecord(fullRemotePathToPathInDb, &rec) && rec.isValid()) {
+        // found folder locally, going to open
+        qCInfo(lcActivity) << "Opening locally a folder" << fullRemotePath;
+        QDesktopServices::openUrl(QUrl::fromLocalFile(folder->path() + rec.path()));
+        return;
+    }
+
+    // try to open it in browser
+    auto folderUrlForBrowser = Utility::concatUrlPath(_account->account()->url(), QStringLiteral("/index.php/apps/files/"));
+    QUrlQuery urlQuery;
+    urlQuery.addQueryItem(QStringLiteral("dir"), fullRemotePath);
+    folderUrlForBrowser.setQuery(urlQuery);
+    if (!folderUrlForBrowser.scheme().startsWith(QStringLiteral("http"))) {
+        folderUrlForBrowser.setScheme(QStringLiteral("https"));
+    }
+    // open https://server.com/index.php/apps/files/?dir=/group_folder/path
+    qCInfo(lcActivity) << "Opening in browser a folder" << fullRemotePath;
+    Utility::openBrowser(folderUrlForBrowser);
+    return;
+}
+
 void User::login() const
 void User::login() const
 {
 {
     _account->account()->resetRejectedCertificates();
     _account->account()->resetRejectedCertificates();
@@ -945,6 +1031,99 @@ void User::forceSyncNow() const
     FolderMan::instance()->forceSyncForFolder(getFolder());
     FolderMan::instance()->forceSyncForFolder(getFolder());
 }
 }
 
 
+void User::slotAccountCapabilitiesChangedRefreshGroupFolders()
+{
+    if (!_account->account()->capabilities().groupFoldersAvailable()) {
+        if (!_trayFolderInfos.isEmpty()) {
+            _trayFolderInfos.clear();
+            emit groupFoldersChanged();
+        }
+        return;
+    }
+
+    slotFetchGroupFolders();
+}
+
+void User::slotFetchGroupFolders()
+{
+    QNetworkRequest req;
+    req.setRawHeader(QByteArrayLiteral("OCS-APIREQUEST"), QByteArrayLiteral("true"));
+    QUrlQuery query;
+    query.addQueryItem(QLatin1String("format"), QLatin1String("json"));
+    query.addQueryItem(QLatin1String("applicable"), QLatin1String("1"));
+    QUrl groupFolderListUrl = Utility::concatUrlPath(_account->account()->url(), QStringLiteral("/index.php/apps/groupfolders/folders"));
+    groupFolderListUrl.setQuery(query);
+
+    const auto groupFolderListJob = _account->account()->sendRequest(QByteArrayLiteral("GET"), groupFolderListUrl, req);
+    connect(groupFolderListJob, &SimpleNetworkJob::finishedSignal, this, &User::slotGroupFoldersFetched);
+}
+
+void User::slotGroupFoldersFetched(QNetworkReply *reply)
+{
+    Q_ASSERT(reply);
+    if (!reply) {
+        qCWarning(lcActivity) << "Group folders fetch error";
+        return;
+    }
+
+    const auto oldSize = _trayFolderInfos.size();
+    const auto oldTrayFolderInfos = _trayFolderInfos;
+    _trayFolderInfos.clear();
+
+    const auto replyData = reply->readAll();
+    if (reply->error() != QNetworkReply::NoError) {
+        if (oldSize != _trayFolderInfos.size()) {
+            emit groupFoldersChanged();
+        }
+        qCWarning(lcActivity) << "Group folders fetch error" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() << replyData;
+        return;
+    }
+
+    QJsonParseError jsonParseError{};
+    const auto json = QJsonDocument::fromJson(replyData, &jsonParseError);
+
+    if (jsonParseError.error != QJsonParseError::NoError) {
+        qCWarning(lcActivity) << "Group folders JSON parse error" << jsonParseError.error << jsonParseError.errorString();
+        if (oldSize != _trayFolderInfos.size()) {
+            emit groupFoldersChanged();
+        }
+        return;
+    }
+
+    const auto obj = json.object().toVariantMap();
+    const auto groupFolders = obj["ocs"].toMap()["data"].toMap();
+
+    for (const auto &groupFolder : groupFolders.values()) {
+        const auto groupFolderInfo = groupFolder.toMap();
+        const auto mountPoint = groupFolderInfo.value(QStringLiteral("mount_point"), {}).toString();
+        parseNewGroupFolderPath(mountPoint);
+    }
+    std::sort(std::begin(_trayFolderInfos), std::end(_trayFolderInfos), [](const auto &leftVariant, const auto &rightVariant) {
+        const auto folderInfoA = leftVariant.template value<TrayFolderInfo>();
+        const auto folderInfoB = rightVariant.template value<TrayFolderInfo>();
+        return folderInfoA._fullPath < folderInfoB._fullPath;
+    });
+
+    if (!_trayFolderInfos.isEmpty()) {
+        if (hasLocalFolder()) {
+            prePendGroupFoldersWithLocalFolder();
+        }
+    }
+
+    if (oldSize != _trayFolderInfos.size()) {
+        emit groupFoldersChanged();
+    } else {
+        for (int i = 0; i < oldTrayFolderInfos.size(); ++i) {
+            const auto oldFolderInfo = oldTrayFolderInfos.at(i).template value<TrayFolderInfo>();
+            const auto newFolderInfo = _trayFolderInfos.at(i).template value<TrayFolderInfo>();
+            if (oldFolderInfo._folderType != newFolderInfo._folderType || oldFolderInfo._fullPath != newFolderInfo._fullPath) {
+                break;
+                emit groupFoldersChanged();
+            }
+        }
+    }
+}
+
 /*-------------------------------------------------------------------------------------*/
 /*-------------------------------------------------------------------------------------*/
 
 
 UserModel *UserModel::_instance = nullptr;
 UserModel *UserModel::_instance = nullptr;
@@ -1105,6 +1284,15 @@ void UserModel::openCurrentAccountServer()
     QDesktopServices::openUrl(url);
     QDesktopServices::openUrl(url);
 }
 }
 
 
+void UserModel::openCurrentAccountFolderFromTrayInfo(const QString &fullRemotePath)
+{
+    if (_currentUserId < 0 || _currentUserId >= _users.size()) {
+        return;
+    }
+
+    _users[_currentUserId]->openFolderLocallyOrInBrowser(fullRemotePath);
+}
+
 void UserModel::setCurrentUserId(const int id)
 void UserModel::setCurrentUserId(const int id)
 {
 {
     Q_ASSERT(id < _users.size());
     Q_ASSERT(id < _users.size());
@@ -1296,7 +1484,6 @@ int UserModel::findUserIdForAccount(AccountState *account) const
     const auto id = std::distance(std::cbegin(_users), it);
     const auto id = std::distance(std::cbegin(_users), it);
     return id;
     return id;
 }
 }
-
 /*-------------------------------------------------------------------------------------*/
 /*-------------------------------------------------------------------------------------*/
 
 
 ImageProvider::ImageProvider()
 ImageProvider::ImageProvider()

+ 35 - 1
src/gui/tray/usermodel.h

@@ -19,6 +19,28 @@
 namespace OCC {
 namespace OCC {
 class UnifiedSearchResultsListModel;
 class UnifiedSearchResultsListModel;
 
 
+
+class TrayFolderInfo
+{
+    Q_GADGET
+
+    Q_PROPERTY(QString name MEMBER _name)
+    Q_PROPERTY(QString parentPath MEMBER _parentPath)
+    Q_PROPERTY(QString fullPath MEMBER _fullPath)
+    Q_PROPERTY(bool isGroupFolder READ isGroupFolder CONSTANT)
+public:
+    enum FolderType { Folder, GroupFolder };
+
+    TrayFolderInfo(const QString &name, const QString &parentPath, const QString &fullPath, FolderType folderType);
+    TrayFolderInfo() = default;
+    [[nodiscard]] bool isGroupFolder() const;
+
+    QString _name;
+    QString _parentPath;
+    QString _fullPath;
+    FolderType _folderType = Folder;
+};
+
 class User : public QObject
 class User : public QObject
 {
 {
     Q_OBJECT
     Q_OBJECT
@@ -37,6 +59,7 @@ class User : public QObject
     Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged)
     Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged)
     Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged)
     Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged)
     Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT)
     Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT)
+    Q_PROPERTY(QVariantList groupFolders READ groupFolders NOTIFY groupFoldersChanged)
 
 
 public:
 public:
     User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr);
     User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr);
@@ -51,6 +74,7 @@ public:
     ActivityListModel *getActivityModel();
     ActivityListModel *getActivityModel();
     [[nodiscard]] UnifiedSearchResultsListModel *getUnifiedSearchResultsListModel() const;
     [[nodiscard]] UnifiedSearchResultsListModel *getUnifiedSearchResultsListModel() const;
     void openLocalFolder();
     void openLocalFolder();
+    void openFolderLocallyOrInBrowser(const QString &fullRemotePath);
     [[nodiscard]] QString name() const;
     [[nodiscard]] QString name() const;
     [[nodiscard]] QString server(bool shortened = true) const;
     [[nodiscard]] QString server(bool shortened = true) const;
     [[nodiscard]] bool hasLocalFolder() const;
     [[nodiscard]] bool hasLocalFolder() const;
@@ -73,6 +97,7 @@ public:
     [[nodiscard]] QUrl statusIcon() const;
     [[nodiscard]] QUrl statusIcon() const;
     [[nodiscard]] QString statusEmoji() const;
     [[nodiscard]] QString statusEmoji() const;
     void processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr &item);
     void processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr &item);
+    [[nodiscard]] const QVariantList &groupFolders() const;
 
 
 signals:
 signals:
     void nameChanged();
     void nameChanged();
@@ -86,6 +111,7 @@ signals:
     void headerTextColorChanged();
     void headerTextColorChanged();
     void accentColorChanged();
     void accentColorChanged();
     void sendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo);
     void sendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo);
+    void groupFoldersChanged();
 
 
 public slots:
 public slots:
     void slotItemCompleted(const QString &folder, const OCC::SyncFileItemPtr &item);
     void slotItemCompleted(const QString &folder, const OCC::SyncFileItemPtr &item);
@@ -109,6 +135,8 @@ public slots:
     void slotRebuildNavigationAppList();
     void slotRebuildNavigationAppList();
     void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo);
     void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo);
     void forceSyncNow() const;
     void forceSyncNow() const;
+    void slotAccountCapabilitiesChangedRefreshGroupFolders();
+    void slotFetchGroupFolders();
 
 
 private slots:
 private slots:
     void slotPushNotificationsReady();
     void slotPushNotificationsReady();
@@ -116,7 +144,7 @@ private slots:
     void slotReceivedPushNotification(OCC::Account *account);
     void slotReceivedPushNotification(OCC::Account *account);
     void slotReceivedPushActivity(OCC::Account *account);
     void slotReceivedPushActivity(OCC::Account *account);
     void slotCheckExpiredActivities();
     void slotCheckExpiredActivities();
-
+    void slotGroupFoldersFetched(QNetworkReply *reply);
     void checkNotifiedNotifications();
     void checkNotifiedNotifications();
     void showDesktopNotification(const QString &title, const QString &message, const long notificationId);
     void showDesktopNotification(const QString &title, const QString &message, const long notificationId);
     void showDesktopNotification(const OCC::Activity &activity);
     void showDesktopNotification(const OCC::Activity &activity);
@@ -124,6 +152,8 @@ private slots:
     void showDesktopTalkNotification(const OCC::Activity &activity);
     void showDesktopTalkNotification(const OCC::Activity &activity);
 
 
 private:
 private:
+    void prePendGroupFoldersWithLocalFolder();
+    void parseNewGroupFolderPath(const QString &path);
     void connectPushNotifications() const;
     void connectPushNotifications() const;
     [[nodiscard]] bool checkPushNotificationsAreReady() const;
     [[nodiscard]] bool checkPushNotificationsAreReady() const;
 
 
@@ -138,6 +168,8 @@ private:
     ActivityListModel *_activityModel;
     ActivityListModel *_activityModel;
     UnifiedSearchResultsListModel *_unifiedSearchResultsModel;
     UnifiedSearchResultsListModel *_unifiedSearchResultsModel;
     ActivityList _blacklistedNotifications;
     ActivityList _blacklistedNotifications;
+    
+    QVariantList _trayFolderInfos;
 
 
     QTimer _expiredActivitiesCheckTimer;
     QTimer _expiredActivitiesCheckTimer;
     QTimer _notificationCheckTimer;
     QTimer _notificationCheckTimer;
@@ -158,6 +190,7 @@ class UserModel : public QAbstractListModel
     Q_PROPERTY(User* currentUser READ currentUser NOTIFY currentUserChanged)
     Q_PROPERTY(User* currentUser READ currentUser NOTIFY currentUserChanged)
     Q_PROPERTY(int currentUserId READ currentUserId WRITE setCurrentUserId NOTIFY currentUserChanged)
     Q_PROPERTY(int currentUserId READ currentUserId WRITE setCurrentUserId NOTIFY currentUserChanged)
 public:
 public:
+
     static UserModel *instance();
     static UserModel *instance();
     ~UserModel() override = default;
     ~UserModel() override = default;
 
 
@@ -208,6 +241,7 @@ public slots:
     void openCurrentAccountLocalFolder();
     void openCurrentAccountLocalFolder();
     void openCurrentAccountTalk();
     void openCurrentAccountTalk();
     void openCurrentAccountServer();
     void openCurrentAccountServer();
+    void openCurrentAccountFolderFromTrayInfo(const QString &fullRemotePath);
     void setCurrentUserId(const int id);
     void setCurrentUserId(const int id);
     void login(const int id);
     void login(const int id);
     void logout(const int id);
     void logout(const int id);

+ 5 - 0
src/libsync/capabilities.cpp

@@ -350,6 +350,11 @@ bool Capabilities::uploadConflictFiles() const
     return _capabilities[QStringLiteral("uploadConflictFiles")].toBool();
     return _capabilities[QStringLiteral("uploadConflictFiles")].toBool();
 }
 }
 
 
+bool Capabilities::groupFoldersAvailable() const
+{
+    return _capabilities[QStringLiteral("groupfolders")].toMap().value(QStringLiteral("hasGroupFolders"), false).toBool();
+}
+
 QStringList Capabilities::blacklistedFiles() const
 QStringList Capabilities::blacklistedFiles() const
 {
 {
     return _capabilities["files"].toMap()["blacklisted_files"].toStringList();
     return _capabilities["files"].toMap()["blacklisted_files"].toStringList();

+ 2 - 0
src/libsync/capabilities.h

@@ -167,6 +167,8 @@ public:
      */
      */
     [[nodiscard]] bool uploadConflictFiles() const;
     [[nodiscard]] bool uploadConflictFiles() const;
 
 
+    [[nodiscard]] bool groupFoldersAvailable() const;
+
     // Direct Editing
     // Direct Editing
     void addDirectEditor(DirectEditor* directEditor);
     void addDirectEditor(DirectEditor* directEditor);
     DirectEditor* getDirectEditorForMimetype(const QMimeType &mimeType);
     DirectEditor* getDirectEditorForMimetype(const QMimeType &mimeType);

+ 1 - 0
src/libsync/discoveryphase.cpp

@@ -368,6 +368,7 @@ void DiscoverySingleDirectoryJob::start()
           << "http://owncloud.org/ns:dDC"
           << "http://owncloud.org/ns:dDC"
           << "http://owncloud.org/ns:permissions"
           << "http://owncloud.org/ns:permissions"
           << "http://owncloud.org/ns:checksums";
           << "http://owncloud.org/ns:checksums";
+
     if (_isRootPath)
     if (_isRootPath)
         props << "http://owncloud.org/ns:data-fingerprint";
         props << "http://owncloud.org/ns:data-fingerprint";
     if (_account->serverVersionInt() >= Account::makeServerVersion(10, 0, 0)) {
     if (_account->serverVersionInt() >= Account::makeServerVersion(10, 0, 0)) {

+ 1 - 0
theme.qrc.in

@@ -86,6 +86,7 @@
         <file>theme/colored/state-warning-256.png</file>
         <file>theme/colored/state-warning-256.png</file>
         <file>theme/black/folder.png</file>
         <file>theme/black/folder.png</file>
         <file>theme/black/folder.svg</file>
         <file>theme/black/folder.svg</file>
+        <file>theme/black/folder-group.svg</file>
         <file>theme/black/folder@2x.png</file>
         <file>theme/black/folder@2x.png</file>
         <file>theme/white/folder.png</file>
         <file>theme/white/folder.png</file>
         <file>theme/white/folder@2x.png</file>
         <file>theme/white/folder@2x.png</file>

+ 15 - 0
theme/Style/Style.qml

@@ -51,6 +51,7 @@ QtObject {
 
 
     property int standardSpacing: 10
     property int standardSpacing: 10
     property int smallSpacing: 5
     property int smallSpacing: 5
+    property int extraSmallSpacing: 2
 
 
     property int iconButtonWidth: 36
     property int iconButtonWidth: 36
     property int standardPrimaryButtonHeight: 40
     property int standardPrimaryButtonHeight: 40
@@ -131,6 +132,20 @@ QtObject {
 
 
     readonly property int activityContentSpace: 4
     readonly property int activityContentSpace: 4
 
 
+    readonly property double smallIconScaleFactor: 0.6
+
+    readonly property double trayFolderListButtonWidthScaleFactor: 1.75
+    readonly property int trayFolderStatusIndicatorSizeOffset: 2
+    readonly property double trayFolderStatusIndicatorRadiusFactor: 0.5
+    readonly property double trayFolderStatusIndicatorMouseHoverOpacityFactor: 0.2
+
+    readonly property double trayWindowMenuWidthFactor: 0.35
+
+    readonly property int trayWindowMenuOffsetX: -2
+    readonly property int trayWindowMenuOffsetY: 2
+
+    readonly property int trayWindowMenuEntriesMargin: 6
+
     function variableSize(size) {
     function variableSize(size) {
         return size * (1 + Math.min(pixelSize / 100, 1));
         return size * (1 + Math.min(pixelSize / 100, 1));
     }
     }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
theme/black/folder-group.svg


Некоторые файлы не были показаны из-за большого количества измененных файлов