From 3be820d9a3b1a5fe7a6621315492588b6b358acf Mon Sep 17 00:00:00 2001 From: alex-z Date: Fri, 21 Apr 2023 17:48:44 +0200 Subject: [PATCH] Group folder visibility improvements. Show dropdown in tray window. Signed-off-by: alex-z --- resources.qrc | 3 + src/gui/tray/ListItemLineAndSubline.qml | 52 ++++++ src/gui/tray/TrayFolderListItem.qml | 70 ++++++++ src/gui/tray/TrayFoldersMenuButton.qml | 216 +++++++++++++++++++++++ src/gui/tray/UnifiedSearchResultItem.qml | 24 +-- src/gui/tray/Window.qml | 77 ++------ src/gui/tray/usermodel.cpp | 189 +++++++++++++++++++- src/gui/tray/usermodel.h | 36 +++- src/libsync/capabilities.cpp | 5 + src/libsync/capabilities.h | 2 + src/libsync/discoveryphase.cpp | 1 + theme.qrc.in | 1 + theme/Style/Style.qml | 15 ++ theme/black/folder-group.svg | 1 + 14 files changed, 612 insertions(+), 80 deletions(-) create mode 100644 src/gui/tray/ListItemLineAndSubline.qml create mode 100644 src/gui/tray/TrayFolderListItem.qml create mode 100644 src/gui/tray/TrayFoldersMenuButton.qml create mode 100644 theme/black/folder-group.svg diff --git a/resources.qrc b/resources.qrc index 976c534b7..1ec7ad1fa 100644 --- a/resources.qrc +++ b/resources.qrc @@ -52,5 +52,8 @@ theme/Style/Style.qml theme/Style/qmldir src/gui/filedetails/NCRadioButton.qml + src/gui/tray/ListItemLineAndSubline.qml + src/gui/tray/TrayFoldersMenuButton.qml + src/gui/tray/TrayFolderListItem.qml diff --git a/src/gui/tray/ListItemLineAndSubline.qml b/src/gui/tray/ListItemLineAndSubline.qml new file mode 100644 index 000000000..a226d8b8e --- /dev/null +++ b/src/gui/tray/ListItemLineAndSubline.qml @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 by Oleksandr Zolotov + * + * 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 + } +} diff --git a/src/gui/tray/TrayFolderListItem.qml b/src/gui/tray/TrayFolderListItem.qml new file mode 100644 index 000000000..f9325aea0 --- /dev/null +++ b/src/gui/tray/TrayFolderListItem.qml @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 by Oleksandr Zolotov + * + * 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 + } + } +} diff --git a/src/gui/tray/TrayFoldersMenuButton.qml b/src/gui/tray/TrayFoldersMenuButton.qml new file mode 100644 index 000000000..20fb3d8de --- /dev/null +++ b/src/gui/tray/TrayFoldersMenuButton.qml @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2023 by Oleksandr Zolotov + * + * 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 + } + } + } +} diff --git a/src/gui/tray/UnifiedSearchResultItem.qml b/src/gui/tray/UnifiedSearchResultItem.qml index 8909a372d..3a3c2edfd 100644 --- a/src/gui/tray/UnifiedSearchResultItem.qml +++ b/src/gui/tray/UnifiedSearchResultItem.qml @@ -24,6 +24,7 @@ RowLayout { property color titleColor: Style.ncTextColor property color sublineColor: Style.ncSecondaryTextColor + Accessible.role: Accessible.ListItem Accessible.name: resultTitle Accessible.onPressAction: unifiedSearchResultMouseArea.clicked() @@ -79,29 +80,16 @@ RowLayout { } } - ColumnLayout { + ListItemLineAndSubline { id: unifiedSearchResultTextContainer + spacing: Style.standardSpacing + Layout.fillWidth: true 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, " ") } } diff --git a/src/gui/tray/Window.qml b/src/gui/tray/Window.qml index fadd3a729..84bb841c3 100644 --- a/src/gui/tray/Window.qml +++ b/src/gui/tray/Window.qml @@ -83,6 +83,7 @@ ApplicationWindow { if(Systray.isOpen) { accountMenu.close(); appsMenu.close(); + openLocalFolderButton.closeMenu() } } @@ -466,25 +467,25 @@ ApplicationWindow { id: currentAccountStatusIndicatorBackground visible: UserModel.currentUser.isConnected && UserModel.currentUser.serverHasUserStatus - width: Style.accountAvatarStateIndicatorSize + 2 + width: Style.accountAvatarStateIndicatorSize + + Style.trayFolderStatusIndicatorSizeOffset height: width anchors.bottom: currentAccountAvatar.bottom anchors.right: currentAccountAvatar.right color: Style.currentUserHeaderColor - radius: width*0.5 + radius: width * Style.trayFolderStatusIndicatorRadiusFactor } Rectangle { id: currentAccountStatusIndicatorMouseHover visible: UserModel.currentUser.isConnected && UserModel.currentUser.serverHasUserStatus - width: Style.accountAvatarStateIndicatorSize + 2 + width: Style.accountAvatarStateIndicatorSize + + Style.trayFolderStatusIndicatorSizeOffset height: width anchors.bottom: currentAccountAvatar.bottom anchors.right: currentAccountAvatar.right color: currentAccountButton.hovered ? Style.currentUserHeaderTextColor : "transparent" - opacity: 0.2 - radius: width*0.5 + opacity: Style.trayFolderStatusIndicatorMouseHoverOpacityFactor + radius: width * Style.trayFolderStatusIndicatorRadiusFactor } Image { @@ -586,62 +587,18 @@ ApplicationWindow { Layout.fillWidth: true } - RowLayout { - id: openLocalFolderRowLayout - spacing: 0 - Layout.preferredWidth: Style.trayWindowHeaderHeight - Layout.preferredHeight: Style.trayWindowHeaderHeight - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + TrayFoldersMenuButton { + id: openLocalFolderButton - Accessible.role: Accessible.Button - Accessible.name: qsTr("Open local folder of current account") + visible: currentUser.hasLocalFolder + currentUser: UserModel.currentUser - HeaderButton { - id: openLocalFolderButton - visible: UserModel.currentUser.hasLocalFolder - icon.source: "qrc:///client/theme/white/folder.svg" - icon.color: Style.currentUserHeaderTextColor - onClicked: UserModel.openCurrentAccountLocalFolder() + Layout.preferredWidth: Style.iconButtonWidth * Style.trayFolderListButtonWidthScaleFactor + Layout.alignment: Qt.AlignHCenter - Image { - id: folderStateIndicator - visible: UserModel.currentUser.hasLocalFolder - source: UserModel.currentUser.isConnected - ? Style.stateOnlineImageSource - : Style.stateOfflineImageSource - cache: false + onClicked: openLocalFolderButton.userHasGroupFolders ? openLocalFolderButton.toggleMenuOpen() : UserModel.openCurrentAccountLocalFolder() - anchors.top: openLocalFolderButton.verticalCenter - anchors.left: openLocalFolderButton.horizontalCenter - sourceSize.width: Style.folderStateIndicatorSize - sourceSize.height: Style.folderStateIndicatorSize - - 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 - } - - 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 { @@ -678,9 +635,9 @@ ApplicationWindow { Menu { 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 closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index deae25c51..f3dc30c08 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -36,6 +36,19 @@ constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; } 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) : 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::accentColorChanged); + connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders); + connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest); 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().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 { 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) { 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 { _account->account()->resetRejectedCertificates(); @@ -945,6 +1031,99 @@ void User::forceSyncNow() const 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(); + const auto folderInfoB = rightVariant.template value(); + 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(); + const auto newFolderInfo = _trayFolderInfos.at(i).template value(); + if (oldFolderInfo._folderType != newFolderInfo._folderType || oldFolderInfo._fullPath != newFolderInfo._fullPath) { + break; + emit groupFoldersChanged(); + } + } + } +} + /*-------------------------------------------------------------------------------------*/ UserModel *UserModel::_instance = nullptr; @@ -1105,6 +1284,15 @@ void UserModel::openCurrentAccountServer() 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) { Q_ASSERT(id < _users.size()); @@ -1296,7 +1484,6 @@ int UserModel::findUserIdForAccount(AccountState *account) const const auto id = std::distance(std::cbegin(_users), it); return id; } - /*-------------------------------------------------------------------------------------*/ ImageProvider::ImageProvider() diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index c287fe82c..e17059969 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -19,6 +19,28 @@ namespace OCC { 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 { Q_OBJECT @@ -37,6 +59,7 @@ class User : public QObject Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged) Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged) Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT) + Q_PROPERTY(QVariantList groupFolders READ groupFolders NOTIFY groupFoldersChanged) public: User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr); @@ -51,6 +74,7 @@ public: ActivityListModel *getActivityModel(); [[nodiscard]] UnifiedSearchResultsListModel *getUnifiedSearchResultsListModel() const; void openLocalFolder(); + void openFolderLocallyOrInBrowser(const QString &fullRemotePath); [[nodiscard]] QString name() const; [[nodiscard]] QString server(bool shortened = true) const; [[nodiscard]] bool hasLocalFolder() const; @@ -73,6 +97,7 @@ public: [[nodiscard]] QUrl statusIcon() const; [[nodiscard]] QString statusEmoji() const; void processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr &item); + [[nodiscard]] const QVariantList &groupFolders() const; signals: void nameChanged(); @@ -86,6 +111,7 @@ signals: void headerTextColorChanged(); void accentColorChanged(); void sendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); + void groupFoldersChanged(); public slots: void slotItemCompleted(const QString &folder, const OCC::SyncFileItemPtr &item); @@ -109,6 +135,8 @@ public slots: void slotRebuildNavigationAppList(); void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void forceSyncNow() const; + void slotAccountCapabilitiesChangedRefreshGroupFolders(); + void slotFetchGroupFolders(); private slots: void slotPushNotificationsReady(); @@ -116,7 +144,7 @@ private slots: void slotReceivedPushNotification(OCC::Account *account); void slotReceivedPushActivity(OCC::Account *account); void slotCheckExpiredActivities(); - + void slotGroupFoldersFetched(QNetworkReply *reply); void checkNotifiedNotifications(); void showDesktopNotification(const QString &title, const QString &message, const long notificationId); void showDesktopNotification(const OCC::Activity &activity); @@ -124,6 +152,8 @@ private slots: void showDesktopTalkNotification(const OCC::Activity &activity); private: + void prePendGroupFoldersWithLocalFolder(); + void parseNewGroupFolderPath(const QString &path); void connectPushNotifications() const; [[nodiscard]] bool checkPushNotificationsAreReady() const; @@ -138,6 +168,8 @@ private: ActivityListModel *_activityModel; UnifiedSearchResultsListModel *_unifiedSearchResultsModel; ActivityList _blacklistedNotifications; + + QVariantList _trayFolderInfos; QTimer _expiredActivitiesCheckTimer; QTimer _notificationCheckTimer; @@ -158,6 +190,7 @@ class UserModel : public QAbstractListModel Q_PROPERTY(User* currentUser READ currentUser NOTIFY currentUserChanged) Q_PROPERTY(int currentUserId READ currentUserId WRITE setCurrentUserId NOTIFY currentUserChanged) public: + static UserModel *instance(); ~UserModel() override = default; @@ -208,6 +241,7 @@ public slots: void openCurrentAccountLocalFolder(); void openCurrentAccountTalk(); void openCurrentAccountServer(); + void openCurrentAccountFolderFromTrayInfo(const QString &fullRemotePath); void setCurrentUserId(const int id); void login(const int id); void logout(const int id); diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index 12b049823..386880b12 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -350,6 +350,11 @@ bool Capabilities::uploadConflictFiles() const return _capabilities[QStringLiteral("uploadConflictFiles")].toBool(); } +bool Capabilities::groupFoldersAvailable() const +{ + return _capabilities[QStringLiteral("groupfolders")].toMap().value(QStringLiteral("hasGroupFolders"), false).toBool(); +} + QStringList Capabilities::blacklistedFiles() const { return _capabilities["files"].toMap()["blacklisted_files"].toStringList(); diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index a65329de0..826d8e6b9 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -167,6 +167,8 @@ public: */ [[nodiscard]] bool uploadConflictFiles() const; + [[nodiscard]] bool groupFoldersAvailable() const; + // Direct Editing void addDirectEditor(DirectEditor* directEditor); DirectEditor* getDirectEditorForMimetype(const QMimeType &mimeType); diff --git a/src/libsync/discoveryphase.cpp b/src/libsync/discoveryphase.cpp index 54e8e4c38..bc83426e0 100644 --- a/src/libsync/discoveryphase.cpp +++ b/src/libsync/discoveryphase.cpp @@ -368,6 +368,7 @@ void DiscoverySingleDirectoryJob::start() << "http://owncloud.org/ns:dDC" << "http://owncloud.org/ns:permissions" << "http://owncloud.org/ns:checksums"; + if (_isRootPath) props << "http://owncloud.org/ns:data-fingerprint"; if (_account->serverVersionInt() >= Account::makeServerVersion(10, 0, 0)) { diff --git a/theme.qrc.in b/theme.qrc.in index cdc669023..8d6996aee 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -86,6 +86,7 @@ theme/colored/state-warning-256.png theme/black/folder.png theme/black/folder.svg + theme/black/folder-group.svg theme/black/folder@2x.png theme/white/folder.png theme/white/folder@2x.png diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 1d788c55d..7d2370b99 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -51,6 +51,7 @@ QtObject { property int standardSpacing: 10 property int smallSpacing: 5 + property int extraSmallSpacing: 2 property int iconButtonWidth: 36 property int standardPrimaryButtonHeight: 40 @@ -131,6 +132,20 @@ QtObject { 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) { return size * (1 + Math.min(pixelSize / 100, 1)); } diff --git a/theme/black/folder-group.svg b/theme/black/folder-group.svg new file mode 100644 index 000000000..7862905e2 --- /dev/null +++ b/theme/black/folder-group.svg @@ -0,0 +1 @@ +