mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 06:18:00 +00:00
Fixed the file filters of the native file dialog for nested file types (#1079)
This commit is contained in:
1 parent
274d718314
commit
055cce80a0
9 files changed
+193
-81
No files matched your search
@@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// The file type filter of a native file dialog, as the runtime takes it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This mirrors the struct FileTypeFilter in runtime/src/file_actions.rs, which knows one name and
|
||||
/// one flat list of extensions. A tree of file types never reaches the runtime: FileTypes.ToFileDialogFilter
|
||||
/// flattens it first, because the runtime would drop the children.
|
||||
/// </remarks>
|
||||
/// <param name="FilterName">The name of the filter, as some file dialogs show it.</param>
|
||||
/// <param name="FilterExtensions">The file extensions to show, without dot.</param>
|
||||
public sealed record FileDialogFilter(string FilterName, string[] FilterExtensions);
|
||||
@@ -1,41 +0,0 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file type that can optionally contain child file types.
|
||||
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
|
||||
/// </summary>
|
||||
/// <param name="FilterName">Display name of the type (e.g., "Document").</param>
|
||||
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
|
||||
/// <param name="Children">Nested file types that are included when this type is selected.</param>
|
||||
public sealed record FileType(string FilterName, string[] FilterExtensions, IReadOnlyList<FileType> Children)
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory for a leaf node.
|
||||
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
|
||||
/// </summary>
|
||||
public static FileType Leaf(string name, params string[] extensions) =>
|
||||
new(name, extensions, []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a parent node that only has children.
|
||||
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
|
||||
/// </summary>
|
||||
public static FileType Parent(string name, params FileType[]? children) =>
|
||||
new(name, [], children ?? []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a composite node that has its own extensions in addition to children.
|
||||
/// </summary>
|
||||
public static FileType Composite(string name, string[] extensions, params FileType[] children) =>
|
||||
new(name, extensions, children);
|
||||
|
||||
/// <summary>
|
||||
/// Collects all extensions for this type, including children.
|
||||
/// </summary>
|
||||
public IEnumerable<string> FlattenExtensions()
|
||||
{
|
||||
return this.FilterExtensions
|
||||
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -2,43 +2,75 @@ namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a file type that can optionally contain child file types.
|
||||
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
|
||||
/// Use the static helpers Leaf, Parent, Composite, ByFileName, and ByFileNamePrefix to build readable trees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File names are kept apart from the extensions: a native file picker filters by extension only, and
|
||||
/// a file name such as Dockerfile is no extension of any file.
|
||||
/// </remarks>
|
||||
/// <param name="FilterName">Display name of the type (e.g., "Document").</param>
|
||||
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
|
||||
/// <param name="Children">Nested file types that are included when this type is selected.</param>
|
||||
public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList<FileTypeFilter> Children)
|
||||
/// <param name="FileNames">Complete file names belonging to this type (e.g., "Makefile").</param>
|
||||
/// <param name="FileNamePrefixes">Beginnings of file names belonging to this type (e.g., "Dockerfile" for "Dockerfile.development").</param>
|
||||
public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList<FileTypeFilter> Children, string[] FileNames, string[] FileNamePrefixes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory for a leaf node.
|
||||
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
|
||||
/// Example: <c>FileTypeFilter.Leaf(".NET", "cs", "razor")</c>
|
||||
/// </summary>
|
||||
public static FileTypeFilter Leaf(string name, params string[] extensions) =>
|
||||
new(name, extensions, []);
|
||||
new(name, extensions, [], [], []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a parent node that only has children.
|
||||
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
|
||||
/// Example: <c>FileTypeFilter.Parent("Source Code", dotnet, java)</c>
|
||||
/// </summary>
|
||||
public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) =>
|
||||
new(name, [], children ?? []);
|
||||
new(name, [], children ?? [], [], []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a composite node that has its own extensions in addition to children.
|
||||
/// </summary>
|
||||
public static FileTypeFilter Composite(string name, string[] extensions, params FileTypeFilter[] children) =>
|
||||
new(name, extensions, children);
|
||||
|
||||
new(name, extensions, children, [], []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a leaf node that matches complete file names instead of extensions.
|
||||
/// Example: <c>FileTypeFilter.ByFileName("Build files", "Makefile", "BUILD")</c>
|
||||
/// </summary>
|
||||
public static FileTypeFilter ByFileName(string name, params string[] fileNames) =>
|
||||
new(name, [], [], fileNames, []);
|
||||
|
||||
/// <summary>
|
||||
/// Factory for a leaf node that matches the beginning of file names instead of extensions.
|
||||
/// Example: <c>FileTypeFilter.ByFileNamePrefix("Container files", "Dockerfile", "Containerfile")</c>
|
||||
/// </summary>
|
||||
public static FileTypeFilter ByFileNamePrefix(string name, params string[] prefixes) =>
|
||||
new(name, [], [], [], prefixes);
|
||||
|
||||
/// <summary>
|
||||
/// Collects all extensions for this type, including children.
|
||||
/// </summary>
|
||||
public IEnumerable<string> FlattenExtensions()
|
||||
public IEnumerable<string> FlattenExtensions() => this.Flatten(type => type.FilterExtensions);
|
||||
|
||||
/// <summary>
|
||||
/// Collects all complete file names for this type, including children.
|
||||
/// </summary>
|
||||
public IEnumerable<string> FlattenFileNames() => this.Flatten(type => type.FileNames);
|
||||
|
||||
/// <summary>
|
||||
/// Collects all file name prefixes for this type, including children.
|
||||
/// </summary>
|
||||
public IEnumerable<string> FlattenFileNamePrefixes() => this.Flatten(type => type.FileNamePrefixes);
|
||||
|
||||
private IEnumerable<string> Flatten(Func<FileTypeFilter, string[]> select)
|
||||
{
|
||||
return this.FilterExtensions
|
||||
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
|
||||
return select(this)
|
||||
.Concat(this.Children.SelectMany(child => child.Flatten(select)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
public bool ContainsType(FileTypeFilter target)
|
||||
{
|
||||
if (this == target)
|
||||
|
||||
@@ -13,12 +13,12 @@ public static class FileTypes
|
||||
{
|
||||
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypes).Namespace, nameof(FileTypes));
|
||||
|
||||
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.Leaf(TB("Source like"),
|
||||
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.ByFileName(TB("Source like"),
|
||||
"Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile",
|
||||
"Tiltfile", "Justfile", "Brewfile", "Caddyfile", "Gemfile", "Podfile", "Fastfile", "Appfile", "Rakefile", "Dangerfile",
|
||||
"BUILD", "WORKSPACE", "BUCK");
|
||||
|
||||
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAME_PREFIXES = FileTypeFilter.Leaf(TB("Source like prefix"),
|
||||
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAME_PREFIXES = FileTypeFilter.ByFileNamePrefix(TB("Source like prefix"),
|
||||
"Dockerfile", "Containerfile", "Jenkinsfile", "Procfile", "Caddyfile");
|
||||
|
||||
// Source code hierarchy: SourceCode -> (.NET, Java, Python, Web, C/C++, Config, ...)
|
||||
@@ -138,15 +138,29 @@ public static class FileTypes
|
||||
/// <returns>True, when reading the file needs Pandoc.</returns>
|
||||
public static bool RequiresPandoc(string filePath) => IsAllowedPath(filePath, PANDOC_CONVERTED);
|
||||
|
||||
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
|
||||
/// <summary>
|
||||
/// Turns the given file types into the one flat filter a native file dialog takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The filter holds the extensions of all given types and of their children. It is named after
|
||||
/// the type when there is only one. Files recognized by their name or content only, such as
|
||||
/// Dockerfile or LICENSE, cannot be expressed by extension: the dialog does not offer them once
|
||||
/// other types narrow the list, whereas drag and drop accepts them. When no extension remains at
|
||||
/// all, there is no filter, the dialog shows every file, and the caller checks the selection
|
||||
/// with IsAllowedPath.
|
||||
/// </remarks>
|
||||
/// <param name="types">The file types to show, or none for all files.</param>
|
||||
/// <returns>The filter, or null when the dialog should show all files.</returns>
|
||||
public static FileDialogFilter? ToFileDialogFilter(params FileTypeFilter[]? types)
|
||||
{
|
||||
if (types == null || types.Length == 0)
|
||||
return null;
|
||||
|
||||
if (types.Length == 1)
|
||||
return types[0];
|
||||
|
||||
return FileTypeFilter.Composite(TB("Custom"), OnlyAllowTypes(types));
|
||||
var extensions = OnlyAllowTypes(types);
|
||||
if (extensions.Length == 0)
|
||||
return null;
|
||||
|
||||
return new FileDialogFilter(types.Length == 1 ? types[0].FilterName : TB("Custom"), extensions);
|
||||
}
|
||||
|
||||
public static string[] OnlyAllowTypes(params FileTypeFilter[] types)
|
||||
@@ -155,7 +169,6 @@ public static class FileTypes
|
||||
return [];
|
||||
|
||||
return types
|
||||
.Where(t => t != SOURCE_LIKE_FILE_NAMES && t != SOURCE_LIKE_FILE_NAME_PREFIXES)
|
||||
.SelectMany(t => t.FlattenExtensions())
|
||||
.Select(ext => ext.ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -192,16 +205,11 @@ public static class FileTypes
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return false;
|
||||
|
||||
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAMES)))
|
||||
{
|
||||
if (SOURCE_LIKE_FILE_NAMES.FilterExtensions.Contains(fileName, StringComparer.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAME_PREFIXES))){
|
||||
if (SOURCE_LIKE_FILE_NAME_PREFIXES.FilterExtensions.Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
}
|
||||
if (types.SelectMany(t => t.FlattenFileNames()).Contains(fileName, StringComparer.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (types.SelectMany(t => t.FlattenFileNamePrefixes()).Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
if (types.Any(type => type.ContainsType(READABLE_TEXT)) && ReadableTextDetection.IsReadableTextFile(filePath))
|
||||
return true;
|
||||
|
||||
@@ -6,5 +6,5 @@ public class SaveFileOptions
|
||||
|
||||
public PreviousFile? PreviousFile { get; init; }
|
||||
|
||||
public FileTypeFilter? Filter { get; init; }
|
||||
public FileDialogFilter? Filter { get; init; }
|
||||
}
|
||||
@@ -6,5 +6,5 @@ public sealed class SelectFileOptions
|
||||
|
||||
public PreviousFile? PreviousFile { get; init; }
|
||||
|
||||
public FileTypeFilter? Filter { get; init; }
|
||||
public FileDialogFilter? Filter { get; init; }
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public sealed class FileEnumerationResult
|
||||
return extension;
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
return FileTypes.SOURCE_LIKE_FILE_NAMES.FilterExtensions
|
||||
return FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames
|
||||
.FirstOrDefault(knownFileName => string.Equals(knownFileName, fileName, StringComparison.OrdinalIgnoreCase))
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed partial class RustService
|
||||
{
|
||||
Title = title,
|
||||
PreviousFile = initialFile is null ? null : new (initialFile),
|
||||
Filter = FileTypes.AsOneFileType(filter)
|
||||
Filter = FileTypes.ToFileDialogFilter(filter)
|
||||
};
|
||||
|
||||
var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions);
|
||||
@@ -81,7 +81,7 @@ public sealed partial class RustService
|
||||
{
|
||||
Title = title,
|
||||
PreviousFile = initialFile is null ? null : new (initialFile),
|
||||
Filter = FileTypes.AsOneFileType(filter)
|
||||
Filter = FileTypes.ToFileDialogFilter(filter)
|
||||
};
|
||||
|
||||
var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions);
|
||||
@@ -112,7 +112,7 @@ public sealed partial class RustService
|
||||
{
|
||||
Title = title,
|
||||
PreviousFile = initialFile is null ? null : new (initialFile),
|
||||
Filter = FileTypes.AsOneFileType(filter)
|
||||
Filter = FileTypes.ToFileDialogFilter(filter)
|
||||
};
|
||||
|
||||
var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Validation;
|
||||
@@ -26,6 +28,105 @@ public class FileTypesTests
|
||||
Assert.That(FileTypes.IsAllowedPath(path, FileTypes.DOCUMENT), Is.True);
|
||||
}
|
||||
|
||||
[TestCase("build")]
|
||||
[TestCase("dockerfile")]
|
||||
[TestCase("makefile")]
|
||||
[TestCase("workspace")]
|
||||
public void FileNamesAreNoExtensions(string extension)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileTypes.IsAllowedExtension(extension, FileTypes.DOCUMENT), Is.False);
|
||||
Assert.That(FileTypes.IsAllowedExtension(extension, FileTypes.SOURCE_CODE), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FlattenedExtensionsHoldNoFileNames()
|
||||
{
|
||||
var fileNames = FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames.Concat(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES.FileNamePrefixes);
|
||||
|
||||
Assert.That(FileTypes.OnlyAllowTypes(FileTypes.DOCUMENT).Intersect(fileNames, StringComparer.OrdinalIgnoreCase), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryExtensionIsLowercaseWithoutDot()
|
||||
{
|
||||
var extensions = typeof(FileTypes)
|
||||
.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.Where(field => field.FieldType == typeof(FileTypeFilter))
|
||||
.SelectMany(field => ((FileTypeFilter)field.GetValue(null)!).FlattenExtensions())
|
||||
.ToArray();
|
||||
|
||||
Assert.That(extensions, Is.Not.Empty);
|
||||
Assert.That(extensions, Has.All.Matches<string>(extension => extension == extension.ToLowerInvariant() && !extension.Contains('.')));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DialogFilterOfAParentHoldsTheExtensionsOfItsChildren()
|
||||
{
|
||||
var filter = FileTypes.ToFileDialogFilter(FileTypes.DOCUMENT);
|
||||
var fileNames = FileTypes.SOURCE_LIKE_FILE_NAMES.FileNames.Concat(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES.FileNamePrefixes);
|
||||
|
||||
Assert.That(filter, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(filter!.FilterName, Is.EqualTo(FileTypes.DOCUMENT.FilterName));
|
||||
Assert.That(filter.FilterExtensions, Is.SupersetOf(new[] { "pdf", "docx", "xlsx", "cs", "csv", "txt", "tex" }));
|
||||
Assert.That(filter.FilterExtensions.Intersect(fileNames, StringComparer.OrdinalIgnoreCase), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DialogFilterOfTextHoldsOnlyItsExtensions()
|
||||
{
|
||||
var filter = FileTypes.ToFileDialogFilter(FileTypes.TEXT);
|
||||
|
||||
Assert.That(filter, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(filter!.FilterName, Is.EqualTo(FileTypes.TEXT.FilterName));
|
||||
Assert.That(filter.FilterExtensions, Is.EquivalentTo(new[] { "txt", "md", "rtf" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DialogFilterOfOneLeafKeepsItsName()
|
||||
{
|
||||
var filter = FileTypes.ToFileDialogFilter(FileTypes.JSON);
|
||||
var combinedFilter = FileTypes.ToFileDialogFilter(FileTypes.JSON, FileTypes.CSV);
|
||||
|
||||
Assert.That(filter, Is.Not.Null);
|
||||
Assert.That(combinedFilter, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(filter!.FilterName, Is.EqualTo("JSON"));
|
||||
Assert.That(filter.FilterExtensions, Is.EqualTo(new[] { "json" }));
|
||||
Assert.That(combinedFilter!.FilterExtensions, Is.EquivalentTo(new[] { "json", "csv" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TypesWithoutExtensionsGiveNoDialogFilter()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.SOURCE_LIKE_FILE_NAMES), Is.Null);
|
||||
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.SOURCE_LIKE_FILE_NAME_PREFIXES), Is.Null);
|
||||
Assert.That(FileTypes.ToFileDialogFilter(), Is.Null);
|
||||
Assert.That(FileTypes.ToFileDialogFilter(null), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DialogFilterMatchesTheRuntimeContract()
|
||||
{
|
||||
// The struct FileTypeFilter in runtime/src/file_actions.rs knows exactly these two fields:
|
||||
var json = JsonSerializer.Serialize(FileTypes.ToFileDialogFilter(FileTypes.JSON), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
|
||||
|
||||
Assert.That(json, Is.EqualTo("""{"filter_name":"JSON","filter_extensions":["json"]}"""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyFiltersWithReadableTextLookAtTheContent()
|
||||
{
|
||||
@@ -46,14 +147,13 @@ public class FileTypesTests
|
||||
[Test]
|
||||
public void ReadableTextDoesNotChangeExtensionFilters()
|
||||
{
|
||||
var combinedFilter = FileTypes.AsOneFileType(FileTypes.JSON, FileTypes.READABLE_TEXT);
|
||||
var combinedFilter = FileTypes.ToFileDialogFilter(FileTypes.JSON, FileTypes.READABLE_TEXT);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(FileTypes.AsOneFileType(FileTypes.READABLE_TEXT), Is.SameAs(FileTypes.READABLE_TEXT));
|
||||
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.READABLE_TEXT), Is.Null);
|
||||
Assert.That(combinedFilter, Is.Not.Null);
|
||||
Assert.That(combinedFilter!.FilterExtensions, Is.EqualTo(new[] { "json" }));
|
||||
Assert.That(FileTypes.AsOneFileType(FileTypes.JSON), Is.SameAs(FileTypes.JSON));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +166,7 @@ public class FileTypesTests
|
||||
Assert.That(FileTypes.TEXT.ContainsType(FileTypes.READABLE_TEXT), Is.True);
|
||||
Assert.That(FileTypes.DOCUMENT.Children, Does.Not.Contain(FileTypes.READABLE_TEXT));
|
||||
Assert.That(FileTypes.DOCUMENT.ContainsType(FileTypes.READABLE_TEXT), Is.True);
|
||||
Assert.That(FileTypes.AsOneFileType(FileTypes.DOCUMENT), Is.Not.Null);
|
||||
Assert.That(FileTypes.ToFileDialogFilter(FileTypes.DOCUMENT), Is.Not.Null);
|
||||
});
|
||||
|
||||
AssertPath("resource \"example\" \"main\" {}", "infrastructure.tf", Encoding.UTF8, true, FileTypes.DOCUMENT);
|
||||
|
||||
Reference in new issue
Block a user