forked from qt-creator/qt-creator
QmlDesigner: Refactor library icon generation for imported 3D assets
Previously, icon generation was done at import time, but that was wasteful, as we now have image cache backed icon generation available for component library icons. Added the few remaining missing bits to support icon generation for image cache and disabled the old icon generation implementation for Qt6. A few issues in fit algorithm for preview image generation were also uncovered and fixed to make icons render scene in comparable size to the old version. Qt5 imports still generate using old way since component library 3D previews generation doesn't work on Qt5. Fixes: QDS-6205 Change-Id: I5418fa19d86e81adcd184be023f1dfbc813d0bf5 Reviewed-by: Mahmoud Badri <mahmoud.badri@qt.io> Reviewed-by: <github-actions-qt-creator@cristianadam.eu> Reviewed-by: Samuel Ghinet <samuel.ghinet@qt.io> Reviewed-by: Qt CI Bot <qt_ci_bot@qt-project.org>
This commit is contained in:
@@ -32,7 +32,6 @@
|
||||
<file>mockfiles/qt6/FadeHandle.qml</file>
|
||||
<file>mockfiles/qt6/HelperGrid.qml</file>
|
||||
<file>mockfiles/qt6/IconGizmo.qml</file>
|
||||
<file>mockfiles/qt6/IconRenderer3D.qml</file>
|
||||
<file>mockfiles/qt6/LightGizmo.qml</file>
|
||||
<file>mockfiles/qt6/LightIconGizmo.qml</file>
|
||||
<file>mockfiles/qt6/LightModel.qml</file>
|
||||
|
@@ -32,7 +32,7 @@ View3D {
|
||||
|
||||
property Material previewMaterial
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// No need to zoom this view, this is here just to avoid runtime warnings
|
||||
}
|
||||
|
@@ -41,6 +41,8 @@ Item {
|
||||
property var modelViewComponent
|
||||
property var nodeViewComponent
|
||||
|
||||
property bool closeUp: false
|
||||
|
||||
function destroyView()
|
||||
{
|
||||
previewObject = null;
|
||||
@@ -96,7 +98,15 @@ Item {
|
||||
|
||||
function fitToViewPort()
|
||||
{
|
||||
view.fitToViewPort();
|
||||
view.fitToViewPort(closeUp);
|
||||
}
|
||||
|
||||
// Enables/disables icon mode. When in icon mode, camera is zoomed bit closer to reduce margins
|
||||
// and the background is removed, in order to generate a preview suitable for library icons.
|
||||
function setIconMode(enable)
|
||||
{
|
||||
closeUp = enable;
|
||||
backgroundRect.visible = !enable;
|
||||
}
|
||||
|
||||
View3D {
|
||||
@@ -108,10 +118,15 @@ Item {
|
||||
id: contentItem
|
||||
anchors.fill: parent
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: viewRect
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: backgroundRect
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 1.0; color: "#222222" }
|
||||
GradientStop { position: 0.0; color: "#999999" }
|
||||
|
@@ -34,11 +34,11 @@ View3D {
|
||||
|
||||
property Model sourceModel
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// The magic number is the distance from camera default pos to origin
|
||||
_generalHelper.calculateNodeBoundsAndFocusCamera(theCamera, importScene, root,
|
||||
1040);
|
||||
_generalHelper.calculateNodeBoundsAndFocusCamera(theCamera, sourceModel, root,
|
||||
1040, closeUp);
|
||||
}
|
||||
|
||||
SceneEnvironment {
|
||||
|
@@ -32,11 +32,11 @@ View3D {
|
||||
environment: sceneEnv
|
||||
camera: theCamera
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// The magic number is the distance from camera default pos to origin
|
||||
_generalHelper.calculateNodeBoundsAndFocusCamera(theCamera, importScene, root,
|
||||
1040);
|
||||
1040, closeUp);
|
||||
}
|
||||
|
||||
SceneEnvironment {
|
||||
|
@@ -1,88 +0,0 @@
|
||||
/****************************************************************************
|
||||
**
|
||||
** Copyright (C) 2020 The Qt Company Ltd.
|
||||
** Contact: https://www.qt.io/licensing/
|
||||
**
|
||||
** This file is part of Qt Creator.
|
||||
**
|
||||
** Commercial License Usage
|
||||
** Licensees holding valid commercial Qt licenses may use this file in
|
||||
** accordance with the commercial license agreement provided with the
|
||||
** Software or, alternatively, in accordance with the terms contained in
|
||||
** a written agreement between you and The Qt Company. For licensing terms
|
||||
** and conditions see https://www.qt.io/terms-conditions. For further
|
||||
** information use the contact form at https://www.qt.io/contact-us.
|
||||
**
|
||||
** GNU General Public License Usage
|
||||
** Alternatively, this file may be used under the terms of the GNU
|
||||
** General Public License version 3 as published by the Free Software
|
||||
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
|
||||
** included in the packaging of this file. Please review the following
|
||||
** information to ensure the GNU General Public License requirements will
|
||||
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
|
||||
**
|
||||
****************************************************************************/
|
||||
|
||||
import QtQuick 6.0
|
||||
import QtQuick3D 6.0
|
||||
|
||||
Item {
|
||||
id: viewRoot
|
||||
width: 1024
|
||||
height: 1024
|
||||
visible: true
|
||||
|
||||
property alias view3D: view3D
|
||||
property alias camPos: viewCamera.position
|
||||
|
||||
function setSceneToBox()
|
||||
{
|
||||
selectionBox.targetNode = view3D.importScene;
|
||||
}
|
||||
|
||||
function fitAndHideBox()
|
||||
{
|
||||
cameraControl.focusObject(selectionBox.model, viewCamera.eulerRotation, true, true);
|
||||
if (cameraControl._zoomFactor < 0.1)
|
||||
view3D.importScene.scale = view3D.importScene.scale.times(10);
|
||||
if (cameraControl._zoomFactor > 10)
|
||||
view3D.importScene.scale = view3D.importScene.scale.times(0.1);
|
||||
|
||||
selectionBox.visible = false;
|
||||
}
|
||||
|
||||
View3D {
|
||||
id: view3D
|
||||
camera: viewCamera
|
||||
environment: sceneEnv
|
||||
|
||||
SceneEnvironment {
|
||||
id: sceneEnv
|
||||
antialiasingMode: SceneEnvironment.MSAA
|
||||
antialiasingQuality: SceneEnvironment.VeryHigh
|
||||
}
|
||||
|
||||
PerspectiveCamera {
|
||||
id: viewCamera
|
||||
position: Qt.vector3d(-200, 200, 200)
|
||||
eulerRotation: Qt.vector3d(-45, -45, 0)
|
||||
}
|
||||
|
||||
DirectionalLight {
|
||||
rotation: viewCamera.rotation
|
||||
}
|
||||
|
||||
SelectionBox {
|
||||
id: selectionBox
|
||||
view3D: view3D
|
||||
geometryName: "SB"
|
||||
}
|
||||
|
||||
EditCameraController {
|
||||
id: cameraControl
|
||||
camera: view3D.camera
|
||||
view3d: view3D
|
||||
ignoreToolState: true
|
||||
}
|
||||
}
|
||||
}
|
@@ -32,7 +32,7 @@ View3D {
|
||||
|
||||
property Material previewMaterial
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// No need to zoom this view, this is here just to avoid runtime warnings
|
||||
}
|
||||
|
@@ -41,6 +41,8 @@ Item {
|
||||
property var modelViewComponent
|
||||
property var nodeViewComponent
|
||||
|
||||
property bool closeUp: false
|
||||
|
||||
function destroyView()
|
||||
{
|
||||
previewObject = null;
|
||||
@@ -96,17 +98,30 @@ Item {
|
||||
|
||||
function fitToViewPort()
|
||||
{
|
||||
view.fitToViewPort();
|
||||
view.fitToViewPort(closeUp);
|
||||
}
|
||||
|
||||
// Enables/disables icon mode. When in icon mode, camera is zoomed bit closer to reduce margins
|
||||
// and the background is removed, in order to generate a preview suitable for library icons.
|
||||
function setIconMode(enable)
|
||||
{
|
||||
closeUp = enable;
|
||||
backgroundRect.visible = !enable;
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentItem
|
||||
anchors.fill: parent
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: viewRect
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: backgroundRect
|
||||
anchors.fill: parent
|
||||
z: -1
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 1.0; color: "#222222" }
|
||||
GradientStop { position: 0.0; color: "#999999" }
|
||||
|
@@ -34,11 +34,11 @@ View3D {
|
||||
|
||||
property Model sourceModel
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// The magic number is the distance from camera default pos to origin
|
||||
_generalHelper.calculateNodeBoundsAndFocusCamera(theCamera, sourceModel, root,
|
||||
1040);
|
||||
1040, closeUp);
|
||||
}
|
||||
|
||||
SceneEnvironment {
|
||||
|
@@ -32,11 +32,11 @@ View3D {
|
||||
environment: sceneEnv
|
||||
camera: theCamera
|
||||
|
||||
function fitToViewPort()
|
||||
function fitToViewPort(closeUp)
|
||||
{
|
||||
// The magic number is the distance from camera default pos to origin
|
||||
_generalHelper.calculateNodeBoundsAndFocusCamera(theCamera, importScene, root,
|
||||
1040);
|
||||
1040, closeUp);
|
||||
}
|
||||
|
||||
SceneEnvironment {
|
||||
|
@@ -308,7 +308,7 @@ QVector4D GeneralHelper::focusNodesToCamera(QQuick3DCamera *camera, float defaul
|
||||
// and recalculating bounds for every frame is not a problem.
|
||||
void GeneralHelper::calculateNodeBoundsAndFocusCamera(
|
||||
QQuick3DCamera *camera, QQuick3DNode *node, QQuick3DViewport *viewPort,
|
||||
float defaultLookAtDistance)
|
||||
float defaultLookAtDistance, bool closeUp)
|
||||
{
|
||||
QVector3D minBounds;
|
||||
QVector3D maxBounds;
|
||||
@@ -317,7 +317,9 @@ void GeneralHelper::calculateNodeBoundsAndFocusCamera(
|
||||
|
||||
QVector3D extents = maxBounds - minBounds;
|
||||
QVector3D lookAt = minBounds + (extents / 2.f);
|
||||
float maxExtent = qMax(extents.x(), qMax(extents.y(), extents.z()));
|
||||
float maxExtent = qSqrt(qreal(extents.x()) * qreal(extents.x())
|
||||
+ qreal(extents.y()) * qreal(extents.y())
|
||||
+ qreal(extents.z()) * qreal(extents.z()));
|
||||
|
||||
// Reset camera position to default zoom
|
||||
QMatrix4x4 m = camera->sceneTransform();
|
||||
@@ -328,9 +330,27 @@ void GeneralHelper::calculateNodeBoundsAndFocusCamera(
|
||||
|
||||
camera->setPosition(lookAt + newLookVector);
|
||||
|
||||
float newZoomFactor = maxExtent / 725.f; // Divisor taken from focusNodesToCamera function
|
||||
// CloseUp divisor is used for icon generation, where we can allow some extreme models to go
|
||||
// slightly out of bounds for better results generally. The other divisor is used for other
|
||||
// previews, where the image is larger to begin with and we would also like some margin
|
||||
// between preview edge and the rendered model, so we can be more conservative with the zoom.
|
||||
// The divisor values are empirically selected to provide nice result.
|
||||
float divisor = closeUp ? 1250.f : 1050.f;
|
||||
float newZoomFactor = maxExtent / divisor;
|
||||
|
||||
zoomCamera(viewPort, camera, 0, defaultLookAtDistance, lookAt, newZoomFactor, false);
|
||||
|
||||
if (auto perspectiveCamera = qobject_cast<QQuick3DPerspectiveCamera *>(camera)) {
|
||||
// Fix camera near/far clips in case we are dealing with extreme zooms
|
||||
const float cameraDist = qAbs((camera->position() - lookAt).length());
|
||||
const float minDist = cameraDist - (maxExtent / 2.f);
|
||||
const float maxDist = cameraDist + (maxExtent / 2.f);
|
||||
if (minDist < perspectiveCamera->clipNear() || maxDist > perspectiveCamera->clipFar()) {
|
||||
perspectiveCamera->setClipNear(minDist * 0.99);
|
||||
perspectiveCamera->setClipFar(maxDist * 1.01);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Aligns any cameras found in nodes list to a camera.
|
||||
@@ -823,12 +843,13 @@ bool GeneralHelper::getBounds(QQuick3DViewport *view3D, QQuick3DNode *node, QVec
|
||||
if (auto childNode = qobject_cast<QQuick3DNode *>(child)) {
|
||||
QVector3D newMinBounds = minBounds;
|
||||
QVector3D newMaxBounds = maxBounds;
|
||||
hasModel = getBounds(view3D, childNode, newMinBounds, newMaxBounds, true);
|
||||
bool childHasModel = getBounds(view3D, childNode, newMinBounds, newMaxBounds, true);
|
||||
// Ignore any subtrees that do not have Model in them as we don't need those
|
||||
// for visual bounds calculations
|
||||
if (hasModel) {
|
||||
if (childHasModel) {
|
||||
minBoundsVec << newMinBounds;
|
||||
maxBoundsVec << newMaxBounds;
|
||||
hasModel = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -77,7 +77,7 @@ public:
|
||||
bool closeUp = false);
|
||||
Q_INVOKABLE void calculateNodeBoundsAndFocusCamera(QQuick3DCamera *camera, QQuick3DNode *node,
|
||||
QQuick3DViewport *viewPort,
|
||||
float defaultLookAtDistance);
|
||||
float defaultLookAtDistance, bool closeUp);
|
||||
Q_INVOKABLE void alignCameras(QQuick3DCamera *camera, const QVariant &nodes);
|
||||
Q_INVOKABLE QVector3D alignView(QQuick3DCamera *camera, const QVariant &nodes,
|
||||
const QVector3D &lookAtPoint);
|
||||
|
@@ -57,6 +57,9 @@ void Quick3DRenderableNodeInstance::initialize(const ObjectNodeInstance::Pointer
|
||||
// In case this is the scene root, we need to create a dummy View3D for the scene
|
||||
// in preview puppets
|
||||
if (instanceId() == 0 && (!nodeInstanceServer()->isInformationServer())) {
|
||||
nodeInstanceServer()->quickWindow()->setDefaultAlphaBuffer(true);
|
||||
nodeInstanceServer()->quickWindow()->setColor(Qt::transparent);
|
||||
|
||||
auto helper = new QmlDesigner::Internal::GeneralHelper();
|
||||
engine()->rootContext()->setContextProperty("_generalHelper", helper);
|
||||
|
||||
@@ -199,6 +202,14 @@ QQuickItem *Quick3DRenderableNodeInstance::contentItem() const
|
||||
return m_dummyRootView;
|
||||
}
|
||||
|
||||
void Quick3DRenderableNodeInstance::setPropertyVariant(const PropertyName &name,
|
||||
const QVariant &value)
|
||||
{
|
||||
if (m_dummyRootView && name == "isLibraryIcon")
|
||||
QMetaObject::invokeMethod(m_dummyRootView, "setIconMode", Q_ARG(QVariant, value));
|
||||
ObjectNodeInstance::setPropertyVariant(name, value);
|
||||
}
|
||||
|
||||
Qt5NodeInstanceServer *Quick3DRenderableNodeInstance::qt5NodeInstanceServer() const
|
||||
{
|
||||
return qobject_cast<Qt5NodeInstanceServer *>(nodeInstanceServer());
|
||||
|
@@ -52,6 +52,7 @@ public:
|
||||
QList<ServerNodeInstance> stateInstances() const override;
|
||||
|
||||
QQuickItem *contentItem() const override;
|
||||
void setPropertyVariant(const PropertyName &name, const QVariant &value) override;
|
||||
|
||||
protected:
|
||||
explicit Quick3DRenderableNodeInstance(QObject *node);
|
||||
|
@@ -401,7 +401,6 @@ void ItemLibraryAssetImporter::postParseQuick3DAsset(const ParseData &pd)
|
||||
qmlInfo.append(outDir.relativeFilePath(qmlIt.filePath()));
|
||||
qmlInfo.append('\n');
|
||||
|
||||
// Generate item library icon for qml file based on root component
|
||||
QFile qmlFile(qmlIt.filePath());
|
||||
if (qmlFile.open(QIODevice::ReadOnly)) {
|
||||
QString iconFileName = outDir.path() + '/'
|
||||
@@ -410,6 +409,8 @@ void ItemLibraryAssetImporter::postParseQuick3DAsset(const ParseData &pd)
|
||||
QString iconFileName2x = iconFileName + "@2x";
|
||||
QByteArray content = qmlFile.readAll();
|
||||
int braceIdx = content.indexOf('{');
|
||||
QString impVersionStr;
|
||||
int impVersionMajor = -1;
|
||||
if (braceIdx != -1) {
|
||||
int nlIdx = content.lastIndexOf('\n', braceIdx);
|
||||
QByteArray rootItem = content.mid(nlIdx, braceIdx - nlIdx).trimmed();
|
||||
@@ -423,8 +424,9 @@ void ItemLibraryAssetImporter::postParseQuick3DAsset(const ParseData &pd)
|
||||
out << "canBeDroppedInView3D: true" << Qt::endl;
|
||||
file.close();
|
||||
|
||||
// Add quick3D import unless it is already added
|
||||
if (m_requiredImports.first().url() != "QtQuick3D") {
|
||||
// Assume that all assets import the same QtQuick3D version,
|
||||
// since they are being imported with same kit
|
||||
if (impVersionMajor == -1) {
|
||||
QByteArray import3dStr{"import QtQuick3D"};
|
||||
int importIdx = content.indexOf(import3dStr);
|
||||
if (importIdx != -1 && importIdx < braceIdx) {
|
||||
@@ -433,15 +435,25 @@ void ItemLibraryAssetImporter::postParseQuick3DAsset(const ParseData &pd)
|
||||
QByteArray versionStr = content.mid(importIdx, nlIdx - importIdx).trimmed();
|
||||
// There could be 'as abc' after version, so just take first part
|
||||
QList<QByteArray> parts = versionStr.split(' ');
|
||||
QString impVersion;
|
||||
if (parts.size() >= 1)
|
||||
impVersion = QString::fromUtf8(parts[0]);
|
||||
if (parts.size() >= 1) {
|
||||
impVersionStr = QString::fromUtf8(parts[0]);
|
||||
if (impVersionStr.isEmpty())
|
||||
impVersionMajor = 6;
|
||||
else
|
||||
impVersionMajor = impVersionStr.left(1).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add quick3D import unless it is already added
|
||||
if (impVersionMajor > 0
|
||||
&& m_requiredImports.first().url() != "QtQuick3D") {
|
||||
m_requiredImports.prepend(Import::createLibraryImport(
|
||||
"QtQuick3D", impVersion));
|
||||
"QtQuick3D", impVersionStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (startIconProcess(24, iconFileName, qmlIt.filePath())) {
|
||||
if (impVersionMajor > 0 && impVersionMajor < 6
|
||||
&& startIconProcess(24, iconFileName, qmlIt.filePath())) {
|
||||
// Since icon is generated by external process, the file won't be
|
||||
// ready for asset gathering below, so assume its generation succeeds
|
||||
// and add it now.
|
||||
|
@@ -92,7 +92,9 @@ QQuickImageResponse *ItemLibraryIconImageProvider::requestImageResponse(const QS
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
},
|
||||
"libIcon",
|
||||
ImageCache::LibraryIconAuxiliaryData{true});
|
||||
|
||||
return response.release();
|
||||
}
|
||||
|
@@ -74,7 +74,7 @@ ImageCacheCollector::~ImageCacheCollector() = default;
|
||||
|
||||
void ImageCacheCollector::start(Utils::SmallStringView name,
|
||||
Utils::SmallStringView state,
|
||||
const ImageCache::AuxiliaryData &,
|
||||
const ImageCache::AuxiliaryData &auxiliaryData,
|
||||
CaptureCallback captureCallback,
|
||||
AbortCallback abortCallback)
|
||||
{
|
||||
@@ -96,13 +96,22 @@ void ImageCacheCollector::start(Utils::SmallStringView name,
|
||||
|
||||
model->setRewriterView(&rewriterView);
|
||||
|
||||
bool is3DRoot = !rewriterView.inErrorState()
|
||||
&& (rewriterView.rootModelNode().isSubclassOf("Quick3D.Node")
|
||||
|| rewriterView.rootModelNode().isSubclassOf("Quick3D.Material"));
|
||||
|
||||
if (rewriterView.inErrorState() || (!rewriterView.rootModelNode().metaInfo().isGraphicalItem()
|
||||
&& !rewriterView.rootModelNode().isSubclassOf("Quick3D.Node") )) {
|
||||
&& !is3DRoot)) {
|
||||
if (abortCallback)
|
||||
abortCallback(ImageCache::AbortReason::Failed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (is3DRoot) {
|
||||
if (auto libIcon = Utils::get_if<ImageCache::LibraryIconAuxiliaryData>(&auxiliaryData))
|
||||
rewriterView.rootModelNode().setAuxiliaryData("isLibraryIcon@NodeInstance", libIcon->enable);
|
||||
}
|
||||
|
||||
ModelNode stateNode = rewriterView.modelNodeForId(QString{state});
|
||||
|
||||
if (stateNode.isValid())
|
||||
|
@@ -54,7 +54,16 @@ public:
|
||||
QString text;
|
||||
};
|
||||
|
||||
using AuxiliaryData = Utils::variant<Utils::monostate, FontCollectorSizeAuxiliaryData, FontCollectorSizesAuxiliaryData>;
|
||||
class LibraryIconAuxiliaryData
|
||||
{
|
||||
public:
|
||||
bool enable;
|
||||
};
|
||||
|
||||
using AuxiliaryData = Utils::variant<Utils::monostate,
|
||||
LibraryIconAuxiliaryData,
|
||||
FontCollectorSizeAuxiliaryData,
|
||||
FontCollectorSizesAuxiliaryData>;
|
||||
|
||||
enum class AbortReason : char { Abort, Failed };
|
||||
|
||||
|
@@ -445,10 +445,9 @@ void SubComponentManager::parseQuick3DAssetsItem(const QString &importUrl, const
|
||||
QString iconPath = qmlIt.fileInfo().absolutePath() + '/'
|
||||
+ Constants::QUICK_3D_ASSET_ICON_DIR + '/' + name
|
||||
+ Constants::QUICK_3D_ASSET_LIBRARY_ICON_SUFFIX;
|
||||
if (!QFileInfo::exists(iconPath))
|
||||
iconPath = defaultIconPath;
|
||||
if (QFileInfo::exists(iconPath))
|
||||
itemLibraryEntry.setLibraryEntryIconPath(iconPath);
|
||||
itemLibraryEntry.setTypeIcon(QIcon(iconPath));
|
||||
itemLibraryEntry.setTypeIcon(QIcon(defaultIconPath));
|
||||
|
||||
// load hints file if exists
|
||||
QFile hintsFile(qmlIt.fileInfo().absolutePath() + '/' + name + ".hints");
|
||||
|
Reference in New Issue
Block a user