Merge pull request #4189 from nextcloud/feature/activity_thumbnails

Add thumbnails for files in the activity view
This commit is contained in:
Claudio Cambra 2022-03-17 11:57:06 +01:00 committed by GitHub
commit 6aefe8f2e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 835 additions and 261 deletions

View file

@ -195,10 +195,10 @@ set(client_SRCS
tray/activitylistmodel.h
tray/activitylistmodel.cpp
tray/unifiedsearchresult.h
tray/asyncimageresponse.cpp
tray/unifiedsearchresult.cpp
tray/unifiedsearchresultimageprovider.h
tray/unifiedsearchresultimageprovider.cpp
tray/unifiedsearchresultslistmodel.h
tray/trayimageprovider.cpp
tray/unifiedsearchresultslistmodel.cpp
tray/usermodel.h
tray/usermodel.cpp

View file

@ -20,7 +20,7 @@
#include "tray/svgimageprovider.h"
#include "tray/usermodel.h"
#include "wheelhandler.h"
#include "tray/unifiedsearchresultimageprovider.h"
#include "tray/trayimageprovider.h"
#include "configfile.h"
#include "accessmanager.h"
@ -65,7 +65,7 @@ void Systray::setTrayEngine(QQmlApplicationEngine *trayEngine)
_trayEngine->addImportPath("qrc:/qml/theme");
_trayEngine->addImageProvider("avatars", new ImageProvider);
_trayEngine->addImageProvider(QLatin1String("svgimage-custom-color"), new OCC::Ui::SvgImageProvider);
_trayEngine->addImageProvider(QLatin1String("unified-search-result-icon"), new UnifiedSearchResultImageProvider);
_trayEngine->addImageProvider(QLatin1String("tray-image-provider"), new TrayImageProvider);
}
Systray::Systray()
@ -513,7 +513,11 @@ AccessManagerFactory::AccessManagerFactory()
QNetworkAccessManager* AccessManagerFactory::create(QObject *parent)
{
return new AccessManager(parent);
const auto am = new AccessManager(parent);
const auto diskCache = new QNetworkDiskCache(am);
diskCache->setCacheDirectory("cacheDir");
am->setCache(diskCache);
return am;
}
} // namespace OCC

View file

@ -38,8 +38,8 @@ MouseArea {
ColumnLayout {
anchors.left: root.left
anchors.right: root.right
anchors.leftMargin: 15
anchors.rightMargin: 10
anchors.leftMargin: 10
spacing: 0

View file

@ -3,6 +3,7 @@ import QtQuick 2.15
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import Style 1.0
import QtGraphicalEffects 1.15
import com.nextcloud.desktopclient 1.0
RowLayout {
@ -19,19 +20,66 @@ RowLayout {
signal dismissButtonClicked()
signal shareButtonClicked()
spacing: 10
Image {
id: activityIcon
spacing: Style.trayHorizontalMargin
Item {
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.preferredWidth: Style.trayListItemIconSize
Layout.preferredHeight: Style.trayListItemIconSize
verticalAlignment: Qt.AlignCenter
source: icon
sourceSize.height: 64
sourceSize.width: 64
Loader {
id: thumbnailImageLoader
anchors.fill: parent
active: model.thumbnail !== undefined
sourceComponent: Item {
anchors.fill: parent
Image {
id: thumbnailImage
width: model.thumbnail.isMimeTypeIcon ? parent.width * 0.85 : parent.width * 0.8
height: model.thumbnail.isMimeTypeIcon ? parent.height * 0.85 : parent.height * 0.8
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
cache: true
source: model.thumbnail.source
visible: false
sourceSize.height: 64
sourceSize.width: 64
}
Rectangle {
id: mask
color: "white"
radius: 3
anchors.fill: thumbnailImage
visible: false
width: thumbnailImage.paintedWidth
height: thumbnailImage.paintedHeight
}
OpacityMask {
anchors.fill: thumbnailImage
source: thumbnailImage
maskSource: mask
visible: model.thumbnail !== undefined
}
}
}
Image {
id: activityIcon
width: model.thumbnail !== undefined ? parent.width * 0.5 : parent.width * 0.85
height: model.thumbnail !== undefined ? parent.height * 0.5 : parent.height * 0.85
anchors.verticalCenter: if(model.thumbnail === undefined) parent.verticalCenter
anchors.left: if(model.thumbnail === undefined) parent.left
anchors.right: if(model.thumbnail !== undefined) parent.right
anchors.bottom: if(model.thumbnail !== undefined) parent.bottom
cache: true
source: icon
sourceSize.height: 64
sourceSize.width: 64
}
}
Column {

View file

@ -11,7 +11,7 @@ RowLayout {
property alias model: syncStatus
spacing: 0
spacing: Style.trayHorizontalMargin
NC.SyncStatusSummary {
id: syncStatus
@ -19,15 +19,18 @@ RowLayout {
Image {
id: syncIcon
Layout.preferredWidth: Style.trayListItemIconSize * 0.85
Layout.preferredHeight: Style.trayListItemIconSize * 0.85
Layout.alignment: Qt.AlignLeft | Qt.AlignVCenter
Layout.topMargin: 16
Layout.rightMargin: Style.trayListItemIconSize * 0.15
Layout.bottomMargin: 16
Layout.leftMargin: 16
Layout.leftMargin: Style.trayHorizontalMargin
source: syncStatus.syncIcon
sourceSize.width: 32
sourceSize.height: 32
sourceSize.width: 64
sourceSize.height: 64
rotation: syncStatus.syncing ? 0 : 0
}
@ -45,8 +48,7 @@ RowLayout {
Layout.alignment: Qt.AlignVCenter
Layout.topMargin: 8
Layout.rightMargin: 16
Layout.leftMargin: 10
Layout.rightMargin: Style.trayHorizontalMargin
Layout.bottomMargin: 8
Layout.fillWidth: true
Layout.fillHeight: true
@ -65,7 +67,7 @@ RowLayout {
Loader {
Layout.fillWidth: true
active: syncStatus.syncing;
active: syncStatus.syncing
visible: syncStatus.syncing
sourceComponent: ProgressBar {

View file

@ -13,15 +13,15 @@ TextField {
readonly property color textFieldIconsColor: Style.menuBorder
readonly property int textFieldIconsOffset: 10
readonly property int textFieldIconsOffset: Style.trayHorizontalMargin
readonly property double textFieldIconsScaleFactor: 0.6
readonly property int textFieldHorizontalPaddingOffset: 14
readonly property int textFieldHorizontalPaddingOffset: Style.trayHorizontalMargin
signal clearText()
leftPadding: trayWindowUnifiedSearchTextFieldSearchIcon.width + trayWindowUnifiedSearchTextFieldSearchIcon.anchors.leftMargin + textFieldHorizontalPaddingOffset
leftPadding: trayWindowUnifiedSearchTextFieldSearchIcon.width + trayWindowUnifiedSearchTextFieldSearchIcon.anchors.leftMargin + textFieldHorizontalPaddingOffset - 1
rightPadding: trayWindowUnifiedSearchTextFieldClearTextButton.width + trayWindowUnifiedSearchTextFieldClearTextButton.anchors.rightMargin + textFieldHorizontalPaddingOffset
placeholderText: qsTr("Search files, messages, events …")
@ -36,6 +36,9 @@ TextField {
Image {
id: trayWindowUnifiedSearchTextFieldSearchIcon
width: Style.trayListItemIconSize - anchors.leftMargin
fillMode: Image.PreserveAspectFit
horizontalAlignment: Image.AlignLeft
anchors {
left: parent.left

View file

@ -39,7 +39,7 @@ RowLayout {
id: unifiedSearchResultThumbnail
visible: false
asynchronous: true
source: "image://unified-search-result-icon/" + icons
source: "image://tray-image-provider/" + icons
cache: true
sourceSize.width: imageData.width
sourceSize.height: imageData.height

View file

@ -22,8 +22,8 @@ Window {
color: "transparent"
flags: Systray.useNormalWindow ? Qt.Window : Qt.Dialog | Qt.FramelessWindowHint
property int fileActivityDialogObjectId: -1
readonly property int maxMenuHeight: Style.trayWindowHeight - Style.trayWindowHeaderHeight - 2 * Style.trayWindowBorderWidth
function openFileActivityDialog(objectName, objectId) {
@ -345,7 +345,7 @@ Window {
Image {
id: currentAccountAvatar
Layout.leftMargin: 8
Layout.leftMargin: Style.trayHorizontalMargin
verticalAlignment: Qt.AlignCenter
cache: false
source: UserModel.currentUser.avatar != "" ? UserModel.currentUser.avatar : "image://avatars/fallbackWhite"
@ -601,9 +601,9 @@ Window {
left: trayWindowBackground.left
right: trayWindowBackground.right
margins: {
top: 10
}
topMargin: Style.trayHorizontalMargin + controlRoot.padding
leftMargin: Style.trayHorizontalMargin + controlRoot.padding
rightMargin: Style.trayHorizontalMargin + controlRoot.padding
}
text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
@ -623,7 +623,7 @@ Window {
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
anchors.left: trayWindowBackground.left
anchors.right: trayWindowBackground.right
anchors.margins: 10
anchors.margins: Style.trayHorizontalMargin
}
UnifiedSearchResultNothingFound {
@ -632,7 +632,7 @@ Window {
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
anchors.left: trayWindowBackground.left
anchors.right: trayWindowBackground.right
anchors.topMargin: 10
anchors.topMargin: Style.trayHorizontalMargin
text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm

View file

@ -15,6 +15,7 @@
#include <QtCore>
#include "activitydata.h"
#include "folderman.h"
namespace OCC {
@ -44,4 +45,99 @@ ActivityLink ActivityLink::createFomJsonObject(const QJsonObject &obj)
return activityLink;
}
OCC::Activity Activity::fromActivityJson(const QJsonObject json, const AccountPtr account)
{
const auto activityUser = json.value(QStringLiteral("user")).toString();
Activity activity;
activity._type = Activity::ActivityType;
activity._objectType = json.value(QStringLiteral("object_type")).toString();
activity._objectId = json.value(QStringLiteral("object_id")).toInt();
activity._objectName = json.value(QStringLiteral("object_name")).toString();
activity._id = json.value(QStringLiteral("activity_id")).toInt();
activity._fileAction = json.value(QStringLiteral("type")).toString();
activity._accName = account->displayName();
activity._subject = json.value(QStringLiteral("subject")).toString();
activity._message = json.value(QStringLiteral("message")).toString();
activity._file = json.value(QStringLiteral("object_name")).toString();
activity._link = QUrl(json.value(QStringLiteral("link")).toString());
activity._dateTime = QDateTime::fromString(json.value(QStringLiteral("datetime")).toString(), Qt::ISODate);
activity._icon = json.value(QStringLiteral("icon")).toString();
activity._isCurrentUserFileActivity = activity._objectType == QStringLiteral("files") && activityUser == account->davUser();
auto richSubjectData = json.value(QStringLiteral("subject_rich")).toArray();
if(richSubjectData.size() > 1) {
activity._subjectRich = richSubjectData[0].toString();
auto parameters = richSubjectData[1].toObject();
const QRegularExpression subjectRichParameterRe(QStringLiteral("({[a-zA-Z0-9]*})"));
const QRegularExpression subjectRichParameterBracesRe(QStringLiteral("[{}]"));
for (auto i = parameters.begin(); i != parameters.end(); ++i) {
const auto parameterJsonObject = i.value().toObject();
activity._subjectRichParameters[i.key()] = Activity::RichSubjectParameter {
parameterJsonObject.value(QStringLiteral("type")).toString(),
parameterJsonObject.value(QStringLiteral("id")).toString(),
parameterJsonObject.value(QStringLiteral("name")).toString(),
parameterJsonObject.contains(QStringLiteral("path")) ? parameterJsonObject.value(QStringLiteral("path")).toString() : QString(),
parameterJsonObject.contains(QStringLiteral("link")) ? QUrl(parameterJsonObject.value(QStringLiteral("link")).toString()) : QUrl(),
};
}
auto displayString = activity._subjectRich;
auto subjectRichParameterMatch = subjectRichParameterRe.globalMatch(displayString);
while (subjectRichParameterMatch.hasNext()) {
const auto match = subjectRichParameterMatch.next();
auto word = match.captured(1);
word.remove(subjectRichParameterBracesRe);
Q_ASSERT(activity._subjectRichParameters.contains(word));
displayString = displayString.replace(match.captured(1), activity._subjectRichParameters[word].name);
}
activity._subjectDisplay = displayString;
}
const auto previewsData = json.value(QStringLiteral("previews")).toArray();
for(const auto preview : previewsData) {
const auto jsonPreviewData = preview.toObject();
PreviewData data;
data._link = jsonPreviewData.value(QStringLiteral("link")).toString();
data._mimeType = jsonPreviewData.value(QStringLiteral("mimeType")).toString();
data._fileId = jsonPreviewData.value(QStringLiteral("fileId")).toInt();
data._view = jsonPreviewData.value(QStringLiteral("view")).toString();
data._filename = jsonPreviewData.value(QStringLiteral("filename")).toString();
if(data._mimeType.contains(QStringLiteral("text/"))) {
data._source = account->url().toString() + QStringLiteral("/index.php/apps/theming/img/core/filetypes/text.svg");
data._isMimeTypeIcon = true;
} else if (data._mimeType.contains(QStringLiteral("/pdf"))) {
data._source = account->url().toString() + QStringLiteral("/index.php/apps/theming/img/core/filetypes/application-pdf.svg");
data._isMimeTypeIcon = true;
} else {
data._source = jsonPreviewData.value(QStringLiteral("source")).toString();
data._isMimeTypeIcon = jsonPreviewData.value(QStringLiteral("isMimeTypeIcon")).toBool();
}
activity._previews.append(data);
}
if(!previewsData.isEmpty()) {
if(activity._icon.contains(QStringLiteral("add-color.svg"))) {
activity._icon = "qrc:///client/theme/colored/add-bordered.svg";
} else if(activity._icon.contains(QStringLiteral("delete-color.svg"))) {
activity._icon = "qrc:///client/theme/colored/delete-bordered.svg";
} else if(activity._icon.contains(QStringLiteral("change.svg"))) {
activity._icon = "qrc:///client/theme/colored/change-bordered.svg";
}
}
return activity;
}
}

View file

@ -19,6 +19,10 @@
#include <QIcon>
#include <QJsonObject>
#include "syncfileitem.h"
#include "folder.h"
#include "account.h"
namespace OCC {
/**
* @brief The ActivityLink class describes actions of an activity
@ -49,6 +53,32 @@ public:
bool _primary;
};
/**
* @brief The PreviewData class describes the data about a file's preview.
*/
class PreviewData
{
Q_GADGET
Q_PROPERTY(QString source MEMBER _source)
Q_PROPERTY(QString link MEMBER _link)
Q_PROPERTY(QString mimeType MEMBER _mimeType)
Q_PROPERTY(int fileId MEMBER _fileId)
Q_PROPERTY(QString view MEMBER _view)
Q_PROPERTY(bool isMimeTypeIcon MEMBER _isMimeTypeIcon)
Q_PROPERTY(QString filename MEMBER _filename)
public:
QString _source;
QString _link;
QString _mimeType;
int _fileId;
QString _view;
bool _isMimeTypeIcon;
QString _filename;
};
/* ==================================================================== */
/**
* @brief Activity Structure
@ -69,6 +99,8 @@ public:
SyncFileItemType
};
static Activity fromActivityJson(const QJsonObject json, const AccountPtr account);
struct RichSubjectParameter {
QString type; // Required
QString id; // Required
@ -97,6 +129,7 @@ public:
QString _accName;
QString _icon;
bool _isCurrentUserFileActivity = false;
QVector<PreviewData> _previews;
// Stores information about the error
int _status;
@ -127,5 +160,6 @@ using ActivityList = QList<Activity>;
Q_DECLARE_METATYPE(OCC::Activity::Type)
Q_DECLARE_METATYPE(OCC::ActivityLink)
Q_DECLARE_METATYPE(OCC::PreviewData)
#endif // ACTIVITYDATA_H

View file

@ -74,6 +74,7 @@ QHash<int, QByteArray> ActivityListModel::roleNames() const
roles[DisplayActions] = "displayActions";
roles[ShareableRole] = "isShareable";
roles[IsCurrentUserFileActivityRole] = "isCurrentUserFileActivity";
roles[ThumbnailRole] = "thumbnail";
return roles;
}
@ -175,6 +176,18 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const
return displayPath == "." || displayPath == "/" ? QString() : displayPath;
};
const auto generatePreviewMap = [](const PreviewData &preview) {
return(QVariantMap {
{QStringLiteral("source"), QStringLiteral("image://tray-image-provider/").append(preview._source)},
{QStringLiteral("link"), preview._link},
{QStringLiteral("mimeType"), preview._mimeType},
{QStringLiteral("fileId"), preview._fileId},
{QStringLiteral("view"), preview._view},
{QStringLiteral("isMimeTypeIcon"), preview._isMimeTypeIcon},
{QStringLiteral("filename"), preview._filename},
});
};
switch (role) {
case DisplayPathRole:
return getDisplayPath();
@ -220,11 +233,14 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const
} else {
// File sync successful
if (a._fileAction == "file_created") {
return "qrc:///client/theme/colored/add.svg";
return a._previews.empty() ? "qrc:///client/theme/colored/add.svg"
: "qrc:///client/theme/colored/add-bordered.svg";
} else if (a._fileAction == "file_deleted") {
return "qrc:///client/theme/colored/delete.svg";
return a._previews.empty() ? "qrc:///client/theme/colored/delete.svg"
: "qrc:///client/theme/colored/delete-bordered.svg";
} else {
return "qrc:///client/theme/change.svg";
return a._previews.empty() ? "qrc:///client/theme/change.svg"
: "qrc:///client/theme/colored/change-bordered.svg";
}
}
} else {
@ -286,6 +302,14 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const
return !data(index, PathRole).toString().isEmpty() && a._objectType == QStringLiteral("files") && _displayActions && a._fileAction != "file_deleted" && a._status != SyncFileItem::FileIgnored;
case IsCurrentUserFileActivityRole:
return a._isCurrentUserFileActivity;
case ThumbnailRole: {
if(a._previews.empty()) {
return {};
}
const auto preview = a._previews[0];
return(generatePreviewMap(preview));
}
default:
return QVariant();
}
@ -324,6 +348,7 @@ void ActivityListModel::startFetchJob()
this, &ActivityListModel::activitiesReceived);
QUrlQuery params;
params.addQueryItem(QLatin1String("previews"), QLatin1String("true"));
params.addQueryItem(QLatin1String("since"), QString::number(_currentItem));
params.addQueryItem(QLatin1String("limit"), QString::number(50));
job->addQueryParams(params);
@ -348,80 +373,17 @@ int ActivityListModel::currentItem() const
return _currentItem;
}
void ActivityListModel::activitiesReceived(const QJsonDocument &json, int statusCode)
void ActivityListModel::ingestActivities(const QJsonArray &activities)
{
auto activities = json.object().value("ocs").toObject().value("data").toArray();
ActivityList list;
auto ast = _accountState;
if (!ast) {
return;
}
if (activities.size() == 0) {
_doneFetching = true;
}
_currentlyFetching = false;
QDateTime oldestDate = QDateTime::currentDateTime();
oldestDate = oldestDate.addDays(_maxActivitiesDays * -1);
foreach (auto activ, activities) {
auto json = activ.toObject();
for (const auto &activ : activities) {
const auto json = activ.toObject();
Activity a;
const auto activityUser = json.value(QStringLiteral("user")).toString();
a._type = Activity::ActivityType;
a._objectType = json.value(QStringLiteral("object_type")).toString();
a._objectId = json.value(QStringLiteral("object_id")).toInt();
a._objectName = json.value(QStringLiteral("object_name")).toString();
a._accName = ast->account()->displayName();
a._id = json.value(QStringLiteral("activity_id")).toInt();
a._fileAction = json.value(QStringLiteral("type")).toString();
a._subject = json.value(QStringLiteral("subject")).toString();
a._message = json.value(QStringLiteral("message")).toString();
a._file = json.value(QStringLiteral("object_name")).toString();
a._link = QUrl(json.value(QStringLiteral("link")).toString());
a._dateTime = QDateTime::fromString(json.value(QStringLiteral("datetime")).toString(), Qt::ISODate);
a._icon = json.value(QStringLiteral("icon")).toString();
a._isCurrentUserFileActivity = a._objectType == QStringLiteral("files") && activityUser == ast->account()->davUser();
auto richSubjectData = json.value(QStringLiteral("subject_rich")).toArray();
if(richSubjectData.size() > 1) {
a._subjectRich = richSubjectData[0].toString();
auto parameters = richSubjectData[1].toObject();
const QRegularExpression subjectRichParameterRe(QStringLiteral("({[a-zA-Z0-9]*})"));
const QRegularExpression subjectRichParameterBracesRe(QStringLiteral("[{}]"));
for (auto i = parameters.begin(); i != parameters.end(); ++i) {
const auto parameterJsonObject = i.value().toObject();
const Activity::RichSubjectParameter parameter = {
parameterJsonObject.value(QStringLiteral("type")).toString(),
parameterJsonObject.value(QStringLiteral("id")).toString(),
parameterJsonObject.value(QStringLiteral("name")).toString(),
parameterJsonObject.contains(QStringLiteral("path")) ? parameterJsonObject.value(QStringLiteral("path")).toString() : QString(),
parameterJsonObject.contains(QStringLiteral("link")) ? QUrl(parameterJsonObject.value(QStringLiteral("link")).toString()) : QUrl(),
};
a._subjectRichParameters[i.key()] = parameter;
}
auto displayString = a._subjectRich;
auto i = subjectRichParameterRe.globalMatch(displayString);
while (i.hasNext()) {
const auto match = i.next();
auto word = match.captured(1);
word.remove(subjectRichParameterBracesRe);
Q_ASSERT(a._subjectRichParameters.contains(word));
displayString = displayString.replace(match.captured(1), a._subjectRichParameters[word].name);
}
a._subjectDisplay = displayString;
}
const auto a = Activity::fromActivityJson(json, _accountState->account());
list.append(a);
_currentItem = list.last()._id;
@ -436,6 +398,23 @@ void ActivityListModel::activitiesReceived(const QJsonDocument &json, int status
}
_activityLists.append(list);
}
void ActivityListModel::activitiesReceived(const QJsonDocument &json, int statusCode)
{
const auto activities = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toArray();
if (!_accountState) {
return;
}
if (activities.empty()) {
_doneFetching = true;
}
_currentlyFetching = false;
ingestActivities(activities);
combineActivityLists();

View file

@ -66,6 +66,7 @@ public:
DisplayActions,
ShareableRole,
IsCurrentUserFileActivityRole,
ThumbnailRole,
};
Q_ENUM(DataRole)
@ -136,6 +137,8 @@ private:
void combineActivityLists();
bool canFetchActivities() const;
void ingestActivities(const QJsonArray &activities);
ActivityList _activityLists;
ActivityList _syncFileItemLists;
ActivityList _notificationLists;

View file

@ -0,0 +1,109 @@
/*
* Copyright (C) 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.
*/
#include <QIcon>
#include <QPainter>
#include <QSvgRenderer>
#include "asyncimageresponse.h"
#include "usermodel.h"
AsyncImageResponse::AsyncImageResponse(const QString &id, const QSize &requestedSize)
{
if (id.isEmpty()) {
setImageAndEmitFinished();
return;
}
_imagePaths = id.split(QLatin1Char(';'), Qt::SkipEmptyParts);
_requestedImageSize = requestedSize;
if (_imagePaths.isEmpty()) {
setImageAndEmitFinished();
} else {
processNextImage();
}
}
void AsyncImageResponse::setImageAndEmitFinished(const QImage &image)
{
_image = image;
emit finished();
}
QQuickTextureFactory* AsyncImageResponse::textureFactory() const
{
return QQuickTextureFactory::textureFactoryForImage(_image);
}
void AsyncImageResponse::processNextImage()
{
if (_index < 0 || _index >= _imagePaths.size()) {
setImageAndEmitFinished();
return;
}
if (_imagePaths.at(_index).startsWith(QStringLiteral(":/client"))) {
setImageAndEmitFinished(QIcon(_imagePaths.at(_index)).pixmap(_requestedImageSize).toImage());
return;
}
const auto currentUser = OCC::UserModel::instance()->currentUser();
if (currentUser && currentUser->account()) {
const QUrl iconUrl(_imagePaths.at(_index));
if (iconUrl.isValid() && !iconUrl.scheme().isEmpty()) {
// fetch the remote resource
const auto reply = currentUser->account()->sendRawRequest(QByteArrayLiteral("GET"), iconUrl);
connect(reply, &QNetworkReply::finished, this, &AsyncImageResponse::slotProcessNetworkReply);
++_index;
return;
}
}
setImageAndEmitFinished();
}
void AsyncImageResponse::slotProcessNetworkReply()
{
const auto reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) {
setImageAndEmitFinished();
return;
}
const QByteArray imageData = reply->readAll();
// server returns "[]" for some some file previews (have no idea why), so, we use another image
// from the list if available
if (imageData.isEmpty() || imageData == QByteArrayLiteral("[]")) {
processNextImage();
} else {
if (imageData.startsWith(QByteArrayLiteral("<svg"))) {
// SVG image needs proper scaling, let's do it with QPainter and QSvgRenderer
QSvgRenderer svgRenderer;
if (svgRenderer.load(imageData)) {
QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32);
scaledSvg.fill("transparent");
QPainter painterForSvg(&scaledSvg);
svgRenderer.render(&painterForSvg);
setImageAndEmitFinished(scaledSvg);
return;
} else {
processNextImage();
}
} else {
setImageAndEmitFinished(QImage::fromData(imageData));
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 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.
*/
#pragma once
#include <QImage>
#include <QQuickImageProvider>
class AsyncImageResponse : public QQuickImageResponse
{
public:
AsyncImageResponse(const QString &id, const QSize &requestedSize);
void setImageAndEmitFinished(const QImage &image = {});
QQuickTextureFactory *textureFactory() const override;
private:
void processNextImage();
private slots:
void slotProcessNetworkReply();
QImage _image;
QStringList _imagePaths;
QSize _requestedImageSize;
int _index = 0;
};

View file

@ -0,0 +1,25 @@
/*
* Copyright (C) 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.
*/
#include "trayimageprovider.h"
#include "asyncimageresponse.h"
namespace OCC {
QQuickImageResponse *TrayImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
{
return new AsyncImageResponse(id, requestedSize);
}
}

View file

@ -20,12 +20,12 @@
namespace OCC {
/**
* @brief The UnifiedSearchResultImageProvider
* @brief The TrayImageProvider
* @ingroup gui
* Allows to fetch Unified Search result icon from the server or used a local resource
* Allows to fetch icon from the server or used a local resource
*/
class UnifiedSearchResultImageProvider : public QQuickAsyncImageProvider
class TrayImageProvider : public QQuickAsyncImageProvider
{
public:
QQuickImageResponse *requestImageResponse(const QString &id, const QSize &requestedSize) override;

View file

@ -1,131 +0,0 @@
/*
* Copyright (C) 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.
*/
#include "unifiedsearchresultimageprovider.h"
#include "usermodel.h"
#include <QImage>
#include <QPainter>
#include <QSvgRenderer>
namespace {
class AsyncImageResponse : public QQuickImageResponse
{
public:
AsyncImageResponse(const QString &id, const QSize &requestedSize)
{
if (id.isEmpty()) {
setImageAndEmitFinished();
return;
}
_imagePaths = id.split(QLatin1Char(';'), Qt::SkipEmptyParts);
_requestedImageSize = requestedSize;
if (_imagePaths.isEmpty()) {
setImageAndEmitFinished();
} else {
processNextImage();
}
}
void setImageAndEmitFinished(const QImage &image = {})
{
_image = image;
emit finished();
}
QQuickTextureFactory *textureFactory() const override
{
return QQuickTextureFactory::textureFactoryForImage(_image);
}
private:
void processNextImage()
{
if (_index < 0 || _index >= _imagePaths.size()) {
setImageAndEmitFinished();
return;
}
if (_imagePaths.at(_index).startsWith(QStringLiteral(":/client"))) {
setImageAndEmitFinished(QIcon(_imagePaths.at(_index)).pixmap(_requestedImageSize).toImage());
return;
}
const auto currentUser = OCC::UserModel::instance()->currentUser();
if (currentUser && currentUser->account()) {
const QUrl iconUrl(_imagePaths.at(_index));
if (iconUrl.isValid() && !iconUrl.scheme().isEmpty()) {
// fetch the remote resource
const auto reply = currentUser->account()->sendRawRequest(QByteArrayLiteral("GET"), iconUrl);
connect(reply, &QNetworkReply::finished, this, &AsyncImageResponse::slotProcessNetworkReply);
++_index;
return;
}
}
setImageAndEmitFinished();
}
private slots:
void slotProcessNetworkReply()
{
const auto reply = qobject_cast<QNetworkReply *>(sender());
if (!reply) {
setImageAndEmitFinished();
return;
}
const QByteArray imageData = reply->readAll();
// server returns "[]" for some some file previews (have no idea why), so, we use another image
// from the list if available
if (imageData.isEmpty() || imageData == QByteArrayLiteral("[]")) {
processNextImage();
} else {
if (imageData.startsWith(QByteArrayLiteral("<svg"))) {
// SVG image needs proper scaling, let's do it with QPainter and QSvgRenderer
QSvgRenderer svgRenderer;
if (svgRenderer.load(imageData)) {
QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32);
scaledSvg.fill("transparent");
QPainter painterForSvg(&scaledSvg);
svgRenderer.render(&painterForSvg);
setImageAndEmitFinished(scaledSvg);
return;
} else {
processNextImage();
}
} else {
setImageAndEmitFinished(QImage::fromData(imageData));
}
}
}
QImage _image;
QStringList _imagePaths;
QSize _requestedImageSize;
int _index = 0;
};
}
namespace OCC {
QQuickImageResponse *UnifiedSearchResultImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
{
return new AsyncImageResponse(id, requestedSize);
}
}

View file

@ -16,6 +16,7 @@
#include "tray/notificationcache.h"
#include "tray/unifiedsearchresultslistmodel.h"
#include "userstatusconnector.h"
#include "thumbnailjob.h"
#include <QDesktopServices>
#include <QIcon>
@ -499,50 +500,88 @@ bool User::isUnsolvableConflict(const SyncFileItemPtr &item) const
void User::processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr &item)
{
const auto fileActionFromInstruction = [](const int instruction) {
if (instruction == CSYNC_INSTRUCTION_REMOVE) {
return QStringLiteral("file_deleted");
} else if (instruction == CSYNC_INSTRUCTION_NEW) {
return QStringLiteral("file_created");
} else if (instruction == CSYNC_INSTRUCTION_RENAME) {
return QStringLiteral("file_renamed");
} else {
return QStringLiteral("file_changed");
}
};
const auto messageFromFileAction = [](const QString &fileAction, const QString &fileName) {
if (fileAction == QStringLiteral("file_renamed")) {
return QObject::tr("You renamed %1").arg(fileName);
} else if (fileAction == QStringLiteral("file_deleted")) {
return QObject:: tr("You deleted %1").arg(fileName);
} else if (fileAction == QStringLiteral("file_created")) {
return QObject::tr("You created %1").arg(fileName);
} else {
return QObject::tr("You changed %1").arg(fileName);
}
};
Activity activity;
activity._type = Activity::SyncFileItemType; //client activity
activity._status = item->_status;
activity._dateTime = QDateTime::currentDateTime();
activity._message = item->_originalFile;
activity._link = folder->accountState()->account()->url();
activity._accName = folder->accountState()->account()->displayName();
activity._link = account()->url();
activity._accName = account()->displayName();
activity._file = item->_file;
activity._folder = folder->alias();
activity._fileAction = "";
activity._objectId = item->_fileId.toInt();
activity._objectName = item->_file;
const auto fileName = QFileInfo(item->_originalFile).fileName();
if (item->_instruction == CSYNC_INSTRUCTION_REMOVE) {
activity._fileAction = "file_deleted";
} else if (item->_instruction == CSYNC_INSTRUCTION_NEW) {
activity._fileAction = "file_created";
} else if (item->_instruction == CSYNC_INSTRUCTION_RENAME) {
activity._fileAction = "file_renamed";
activity._renamedFile = item->_renameTarget;
} else {
activity._fileAction = "file_changed";
}
activity._fileAction = fileActionFromInstruction(item->_instruction);
if (item->_status == SyncFileItem::NoStatus || item->_status == SyncFileItem::Success) {
qCWarning(lcActivity) << "Item " << item->_file << " retrieved successfully.";
if (item->_direction != SyncFileItem::Up) {
activity._message = tr("Synced %1").arg(fileName);
} else if (activity._fileAction == "file_renamed") {
activity._message = tr("You renamed %1").arg(fileName);
} else if (activity._fileAction == "file_deleted") {
activity._message = tr("You deleted %1").arg(fileName);
} else if (activity._fileAction == "file_created") {
activity._message = tr("You created %1").arg(fileName);
activity._message = QObject::tr("Synced %1").arg(fileName);
} else {
activity._message = tr("You changed %1").arg(fileName);
activity._message = messageFromFileAction(activity._fileAction, fileName);
}
if(activity._fileAction != "file_deleted") {
auto remotePath = folder->remotePath();
remotePath.append(activity._fileAction == "file_renamed" ? item->_renameTarget : activity._file);
const auto localFiles = FolderMan::instance()->findFileInLocalFolders(item->_file, account());
if (!localFiles.isEmpty()) {
const QMimeType mimeType = _mimeDb.mimeTypeForFile(QFileInfo(localFiles.constFirst()));
// Set the preview data, though for now we can skip setting file ID, link, and view
PreviewData preview;
preview._mimeType = mimeType.name();
preview._filename = fileName;
if(item->isDirectory()) {
preview._source = account()->url().toString() + QStringLiteral("/index.php/apps/theming/img/core/filetypes/folder.svg");
preview._isMimeTypeIcon = true;
} else if(mimeType.isValid() && mimeType.inherits("text/plain")) {
preview._source = account()->url().toString() + QStringLiteral("/index.php/apps/theming/img/core/filetypes/text.svg");
preview._isMimeTypeIcon = true;
} else if (mimeType.isValid() && mimeType.inherits("application/pdf")) {
preview._source = account()->url().toString() + QStringLiteral("/index.php/apps/theming/img/core/filetypes/application-pdf.svg");
preview._isMimeTypeIcon = true;
} else {
preview._source = account()->url().toString() + QStringLiteral("/index.php/apps/files/api/v1/thumbnail/150/150/") + remotePath;
preview._isMimeTypeIcon = false;
}
activity._previews.append(preview);
}
}
_activityModel->addSyncFileItemToActivityList(activity);
} else {
qCWarning(lcActivity) << "Item " << item->_file << " retrieved resulted in error " << item->_errorString;
activity._subject = item->_errorString;
if (item->_status == SyncFileItem::Status::FileIgnored) {

View file

@ -134,6 +134,7 @@ private:
QElapsedTimer _guiLogTimer;
NotificationCache _notificationCache;
QMimeDatabase _mimeDb;
// number of currently running notification requests. If non zero,
// no query for notifications is started.

View file

@ -62,6 +62,7 @@ nextcloud_add_test(NotificationCache)
nextcloud_add_test(SetUserStatusDialog)
nextcloud_add_test(UnifiedSearchListmodel)
nextcloud_add_test(ActivityListModel)
nextcloud_add_test(ActivityData)
if( UNIX AND NOT APPLE )
nextcloud_add_test(InotifyWatcher)

184
test/testactivitydata.cpp Normal file
View file

@ -0,0 +1,184 @@
/*
* Copyright (C) by Claudio Cambra <claudio.cambra@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.
*/
#include "gui/tray/activitydata.h"
#include "account.h"
#include "accountstate.h"
#include "configfile.h"
#include "syncenginetestutils.h"
#include "syncfileitem.h"
#include "folder.h"
#include "folderman.h"
#include "testhelper.h"
#include <QTest>
class TestActivityData : public QObject
{
Q_OBJECT
public:
TestActivityData() = default;
void createJsonSpecificFormatData(QString fileFormat, QString mimeType)
{
const auto objectType = QStringLiteral("files");
const auto subject = QStringLiteral("You created path/test.").append(fileFormat);
const auto path = QStringLiteral("path/test.").append(fileFormat);
const auto fileName = QStringLiteral("test.").append(fileFormat);
const auto activityType = QStringLiteral("file");
const auto activityId = 90000;
const auto message = QStringLiteral();
const auto objectName = QStringLiteral("test.").append(fileFormat);
const auto link = account->url().toString().append(QStringLiteral("/f/")).append(activityId);
const auto datetime = QDateTime::currentDateTime().toString(Qt::ISODate);
const auto icon = account->url().toString().append(QStringLiteral("/apps/files/img/add-color.svg"));
const QJsonObject richStringData({
{QStringLiteral("type"), activityType},
{QStringLiteral("id"), activityId},
{QStringLiteral("link"), link},
{QStringLiteral("name"), fileName},
{QStringLiteral("path"), objectName}
});
const auto subjectRichString = QStringLiteral("You created {file1}");
const auto subjectRichObj = QJsonObject({{QStringLiteral("file1"), richStringData}});
const auto subjectRichData = QJsonArray({subjectRichString, subjectRichObj});
const auto previewUrl = account->url().toString().append(QStringLiteral("/index.php/core/preview.png?file=/")).append(path);
// Text file previews should be replaced by mimetype icon
const QJsonObject previewData({
{QStringLiteral("link"), link},
{QStringLiteral("mimeType"), mimeType},
{QStringLiteral("fileId"), activityId},
{QStringLiteral("filename"), fileName},
{QStringLiteral("view"), QStringLiteral("files")},
{QStringLiteral("source"), previewUrl},
{QStringLiteral("isMimeTypeIcon"), false},
});
QJsonObject testData({
{QStringLiteral("object_type"), objectType},
{QStringLiteral("activity_id"), activityId},
{QStringLiteral("type"), activityType},
{QStringLiteral("subject"), subject},
{QStringLiteral("message"), message},
{QStringLiteral("object_name"), objectName},
{QStringLiteral("link"), link},
{QStringLiteral("datetime"), datetime},
{QStringLiteral("icon"), icon},
{QStringLiteral("subject_rich"), subjectRichData},
{QStringLiteral("previews"), QJsonArray({previewData})},
});
QTest::addRow("data") << testData << fileFormat << mimeType << objectType << subject << path << fileName << activityType << activityId << message << objectName << link << datetime << icon << subjectRichString << subjectRichData << previewUrl;
}
QScopedPointer<FakeQNAM> fakeQnam;
OCC::AccountPtr account;
private slots:
void initTestCase()
{
account = OCC::Account::create();
account->setCredentials(new FakeCredentials{fakeQnam.data()});
account->setUrl(QUrl(("http://example.de")));
auto *cred = new HttpCredentialsTest("testuser", "secret");
account->setCredentials(cred);
}
void testFromJson_data()
{
QTest::addColumn<QJsonObject>("activityJsonObject");
QTest::addColumn<QString>("fileFormat");
QTest::addColumn<QString>("mimeTypeExpected");
QTest::addColumn<QString>("objectTypeExpected");
QTest::addColumn<QString>("subjectExpected");
QTest::addColumn<QString>("pathExpected");
QTest::addColumn<QString>("fileNameExpected");
QTest::addColumn<QString>("activityTypeExpected");
QTest::addColumn<int>("activityIdExpected");
QTest::addColumn<QString>("messageExpected");
QTest::addColumn<QString>("objectNameExpected");
QTest::addColumn<QString>("linkExpected");
QTest::addColumn<QString>("datetimeExpected");
QTest::addColumn<QString>("iconExpected");
QTest::addColumn<QString>("subjectRichStringExpected");
QTest::addColumn<QJsonArray>("subjectRichDataExpected");
QTest::addColumn<QString>("previewUrlExpected");
createJsonSpecificFormatData(QStringLiteral("jpg"), QStringLiteral("image/jpg"));
createJsonSpecificFormatData(QStringLiteral("txt"), QStringLiteral("text/plain"));
createJsonSpecificFormatData(QStringLiteral("pdf"), QStringLiteral("application/pdf"));
}
void testFromJson()
{
QFETCH(QJsonObject, activityJsonObject);
QFETCH(QString, fileFormat);
QFETCH(QString, mimeTypeExpected);
QFETCH(QString, objectTypeExpected);
QFETCH(QString, subjectExpected);
QFETCH(QString, pathExpected);
QFETCH(QString, fileNameExpected);
QFETCH(QString, activityTypeExpected);
QFETCH(int, activityIdExpected);
QFETCH(QString, messageExpected);
QFETCH(QString, objectNameExpected);
QFETCH(QString, linkExpected);
QFETCH(QString, datetimeExpected);
QFETCH(QString, iconExpected);
QFETCH(QString, subjectRichStringExpected);
QFETCH(QJsonArray, subjectRichDataExpected);
QFETCH(QString, previewUrlExpected);
OCC::Activity activity = OCC::Activity::fromActivityJson(activityJsonObject, account);
QCOMPARE(activity._type, OCC::Activity::ActivityType);
QCOMPARE(activity._objectType, objectTypeExpected);
QCOMPARE(activity._id, activityIdExpected);
QCOMPARE(activity._fileAction, activityTypeExpected);
QCOMPARE(activity._accName, account->displayName());
QCOMPARE(activity._subject, subjectExpected);
QCOMPARE(activity._message, messageExpected);
QCOMPARE(activity._file, objectNameExpected);
QCOMPARE(activity._link, linkExpected);
QCOMPARE(activity._dateTime, QDateTime::fromString(datetimeExpected, Qt::ISODate));
QCOMPARE(activity._subjectRichParameters.count(), 1);
QCOMPARE(activity._subjectDisplay, QStringLiteral("You created ").append(fileNameExpected));
QCOMPARE(activity._previews.count(), 1);
// We want the different icon when we have a preview
//QCOMPARE(activity._icon, iconExpected);
if(fileFormat == "txt") {
QCOMPARE(activity._previews[0]._source, account->url().toString().append(QStringLiteral("/index.php/apps/theming/img/core/filetypes/text.svg")));
QCOMPARE(activity._previews[0]._isMimeTypeIcon, true);
QCOMPARE(activity._previews[0]._mimeType, mimeTypeExpected);
} else if(fileFormat == "pdf") {
QCOMPARE(activity._previews[0]._source, account->url().toString().append(QStringLiteral("/index.php/apps/theming/img/core/filetypes/application-pdf.svg")));
QCOMPARE(activity._previews[0]._isMimeTypeIcon, true);
} else {
QCOMPARE(activity._previews[0]._source, previewUrlExpected);
QCOMPARE(activity._previews[0]._isMimeTypeIcon, false);
}
QCOMPARE(activity._previews[0]._mimeType, mimeTypeExpected);
}
};
QTEST_MAIN(TestActivityData)
#include "testactivitydata.moc"

View file

@ -190,13 +190,16 @@
<file>theme/copy.svg</file>
<file>theme/more.svg</file>
<file>theme/change.svg</file>
<file>theme/colored/change-bordered.svg</file>
<file>theme/lock-http.svg</file>
<file>theme/lock-https.svg</file>
<file>theme/lock-broken.svg</file>
<file>theme/network.svg</file>
<file>theme/account.svg</file>
<file>theme/colored/add.svg</file>
<file>theme/colored/add-bordered.svg</file>
<file>theme/colored/delete.svg</file>
<file>theme/colored/delete-bordered.svg</file>
<file>theme/colored/@APPLICATION_ICON_NAME@-icon.svg</file>
<file>theme/add.svg</file>
<file>theme/share.svg</file>

View file

@ -29,6 +29,8 @@ QtObject {
property int trayWindowRadius: 10
property int trayWindowBorderWidth: 1
property int trayWindowHeaderHeight: variableSize(60)
property int trayHorizontalMargin: 10
property int trayListItemIconSize: accountAvatarSize
property int currentAccountButtonWidth: 220
property int currentAccountButtonRadius: 2
@ -58,7 +60,7 @@ QtObject {
property int roundedButtonBackgroundVerticalMargins: 5
property int userStatusEmojiSize: 8
property int userStatusSpacing: 6
property int userStatusSpacing: trayHorizontalMargin
property int userStatusAnchorsMargin: 2
property int accountServerAnchorsMargin: 10
property int accountLabelsSpacing: 4

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16"
width="16"
version="1.1"
viewbox="0 0 16 16"
id="svg4"
sodipodi:docname="add-bordered.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="39.375"
inkscape:cx="7.9873016"
inkscape:cy="8"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="1"
inkscape:window-y="1111"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
fill="#000000"
d="m 9.02,13.98 h -2 V 8.9799996 h -5 V 6.98 h 5 v -5 h 2 v 5 l 5,-0.028 v 2.0279996 h -5 z"
id="path2929"
style="fill:#000001;fill-opacity:1;stroke:#ffffff;stroke-opacity:1;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none" />
<path
fill="#000"
d="M9.02 13.98h-2v-5h-5v-2h5v-5h2v5l5-.028V8.98h-5z"
id="path2" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 16 16"
width="16"
version="1.1"
height="16"
id="svg6"
sodipodi:docname="change-bordered.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="27.84233"
inkscape:cx="6.0878526"
inkscape:cy="3.9867353"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="1"
inkscape:window-y="1111"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
d="m2 8.75v4.5l1.408-1.41c1.116 1.334 2.817 2.145 4.592 2.16 2.16 0.01827 4.116-1.132 5.196-3.002l-1.948-1.125c-0.677 1.171-1.9005 1.886-3.248 1.875-1.18-0.01-2.3047-0.572-3-1.5l1.5-1.5z"
id="path2731"
style="stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none;stroke:#ffffff;stroke-opacity:1" />
<path
d="M 8,2 C 5.858,2 3.875,3.145 2.804,5 L 4.752,6.125 C 5.423,4.963 6.658,4.25 7.9996,4.25 c 1.1906,0 2.297,0.56157 3,1.5 l -1.5,1.5 h 4.5 v -4.5 l -1.406,1.406 C 11.4646,2.808 9.792,2 8,2 Z"
id="path827"
style="stroke:#ffffff;stroke-opacity:1;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none"
sodipodi:nodetypes="cccsccccccc" />
<path
d="m8 2c-2.142 0-4.125 1.145-5.196 3l1.948 1.125c0.671-1.162 1.906-1.875 3.2476-1.875 1.1906 0 2.297 0.56157 3 1.5l-1.5 1.5h4.5v-4.5l-1.406 1.406c-1.129-1.348-2.802-2.1563-4.594-2.1563z"
id="path2" />
<path
d="m2 8.75v4.5l1.408-1.41c1.116 1.334 2.817 2.145 4.592 2.16 2.16 0.01827 4.116-1.132 5.196-3.002l-1.948-1.125c-0.677 1.171-1.9005 1.886-3.248 1.875-1.18-0.01-2.3047-0.572-3-1.5l1.5-1.5z"
id="path4" />
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16"
width="16"
version="1.1"
viewBox="0 0 16 16"
id="svg4"
sodipodi:docname="delete-bordered.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="39.375"
inkscape:cx="7.9873016"
inkscape:cy="8"
inkscape:window-width="1920"
inkscape:window-height="1020"
inkscape:window-x="1"
inkscape:window-y="1111"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
d="m3.0503 4.4645 3.5355 3.5355-3.5355 3.536 1.4142 1.414 3.5355-3.5358 3.536 3.5358 1.414-1.414-3.5358-3.536 3.5358-3.5355-1.414-1.4142-3.536 3.5355-3.5355-3.5355-1.4142 1.4142z"
fill="#000"
id="path849"
style="stroke:#ffffff;stroke-opacity:1;stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none" />
<path
d="m3.0503 4.4645 3.5355 3.5355-3.5355 3.536 1.4142 1.414 3.5355-3.5358 3.536 3.5358 1.414-1.414-3.5358-3.536 3.5358-3.5355-1.414-1.4142-3.536 3.5355-3.5355-3.5355-1.4142 1.4142z"
fill="#000"
id="path2" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB