mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-03-15 11:22:30 +00:00
C#10ify
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -5,48 +5,47 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class AsyncExtensions
|
||||
{
|
||||
public static class AsyncExtensions
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable)
|
||||
var list = new List<T>();
|
||||
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||
|
||||
public static async ValueTask ParallelForEachAsync<T>(
|
||||
this IEnumerable<T> source,
|
||||
Func<T, ValueTask> handleAsync,
|
||||
int degreeOfParallelism,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||
|
||||
await Task.WhenAll(source.Select(async item =>
|
||||
{
|
||||
var list = new List<T>();
|
||||
// ReSharper disable once AccessToDisposedClosure
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||
|
||||
public static async ValueTask ParallelForEachAsync<T>(
|
||||
this IEnumerable<T> source,
|
||||
Func<T, ValueTask> handleAsync,
|
||||
int degreeOfParallelism,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||
|
||||
await Task.WhenAll(source.Select(async item =>
|
||||
try
|
||||
{
|
||||
await handleAsync(item);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ReSharper disable once AccessToDisposedClosure
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await handleAsync(item);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ReSharper disable once AccessToDisposedClosure
|
||||
semaphore.Release();
|
||||
}
|
||||
}));
|
||||
}
|
||||
semaphore.Release();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class BinaryExtensions
|
||||
{
|
||||
public static class BinaryExtensions
|
||||
public static string ToHex(this byte[] data)
|
||||
{
|
||||
public static string ToHex(this byte[] data)
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
foreach (var t in data)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
foreach (var t in data)
|
||||
{
|
||||
buffer.Append(t.ToString("X2"));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
buffer.Append(t.ToString("X2"));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
using System.Drawing;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class DateExtensions
|
||||
{
|
||||
public static class DateExtensions
|
||||
{
|
||||
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
|
||||
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
|
||||
}
|
||||
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
|
||||
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
|
||||
!predicate(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
|
||||
!predicate(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpContentHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values)
|
||||
? string.Concat(values)
|
||||
: null;
|
||||
}
|
||||
public static string? TryGetValue(this HttpContentHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values)
|
||||
? string.Concat(values)
|
||||
: null;
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static class StringExtensions
|
||||
public static string? NullIfWhiteSpace(this string str) =>
|
||||
!string.IsNullOrWhiteSpace(str)
|
||||
? str
|
||||
: null;
|
||||
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount
|
||||
? str[..charCount]
|
||||
: str;
|
||||
|
||||
public static IEnumerable<Rune> GetRunes(this string str)
|
||||
{
|
||||
public static string? NullIfWhiteSpace(this string str) =>
|
||||
!string.IsNullOrWhiteSpace(str)
|
||||
? str
|
||||
: null;
|
||||
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount
|
||||
? str[..charCount]
|
||||
: str;
|
||||
|
||||
public static IEnumerable<Rune> GetRunes(this string str)
|
||||
var lastIndex = 0;
|
||||
while (lastIndex < str.Length && Rune.TryGetRuneAt(str, lastIndex, out var rune))
|
||||
{
|
||||
var lastIndex = 0;
|
||||
while (lastIndex < str.Length && Rune.TryGetRuneAt(str, lastIndex, out var rune))
|
||||
{
|
||||
yield return rune;
|
||||
lastIndex += rune.Utf16SequenceLength;
|
||||
}
|
||||
yield return rune;
|
||||
lastIndex += rune.Utf16SequenceLength;
|
||||
}
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
: builder;
|
||||
}
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
: builder;
|
||||
}
|
||||
@@ -3,25 +3,24 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using Superpower;
|
||||
using Superpower.Parsers;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class SuperpowerExtensions
|
||||
{
|
||||
public static class SuperpowerExtensions
|
||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
||||
parser.Select(chars => new string(chars));
|
||||
|
||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) => i =>
|
||||
{
|
||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
||||
parser.Select(chars => new string(chars));
|
||||
|
||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) => i =>
|
||||
{
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
}
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
}
|
||||
@@ -1,41 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class FileFormat
|
||||
{
|
||||
public static class FileFormat
|
||||
private static readonly HashSet<string> ImageFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
private static readonly HashSet<string> ImageFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".gifv",
|
||||
".bmp",
|
||||
".webp"
|
||||
};
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".gifv",
|
||||
".bmp",
|
||||
".webp"
|
||||
};
|
||||
|
||||
public static bool IsImage(string format) => ImageFormats.Contains(format);
|
||||
public static bool IsImage(string format) => ImageFormats.Contains(format);
|
||||
|
||||
private static readonly HashSet<string> VideoFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov"
|
||||
};
|
||||
private static readonly HashSet<string> VideoFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov"
|
||||
};
|
||||
|
||||
public static bool IsVideo(string format) => VideoFormats.Contains(format);
|
||||
public static bool IsVideo(string format) => VideoFormats.Contains(format);
|
||||
|
||||
private static readonly HashSet<string> AudioFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".m4a"
|
||||
};
|
||||
private static readonly HashSet<string> AudioFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".m4a"
|
||||
};
|
||||
|
||||
public static bool IsAudio(string format) => AudioFormats.Contains(format);
|
||||
}
|
||||
public static bool IsAudio(string format) => AudioFormats.Contains(format);
|
||||
}
|
||||
@@ -8,60 +8,59 @@ using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class Http
|
||||
{
|
||||
public static class Http
|
||||
{
|
||||
public static HttpClient Client { get; } = new();
|
||||
public static HttpClient Client { get; } = new();
|
||||
|
||||
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>()
|
||||
.Or<HttpRequestException>()
|
||||
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(
|
||||
8,
|
||||
(i, result, _) =>
|
||||
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>()
|
||||
.Or<HttpRequestException>()
|
||||
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(
|
||||
8,
|
||||
(i, result, _) =>
|
||||
{
|
||||
// If rate-limited, use retry-after as a guide
|
||||
if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
// If rate-limited, use retry-after as a guide
|
||||
if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
// Only start respecting retry-after after a few attempts, because
|
||||
// Discord often sends unreasonable (20+ minutes) retry-after
|
||||
// on the very first request.
|
||||
if (i > 3)
|
||||
{
|
||||
// Only start respecting retry-after after a few attempts, because
|
||||
// Discord often sends unreasonable (20+ minutes) retry-after
|
||||
// on the very first request.
|
||||
if (i > 3)
|
||||
{
|
||||
var retryAfterDelay = result.Result.Headers.RetryAfter?.Delta;
|
||||
if (retryAfterDelay is not null)
|
||||
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||
}
|
||||
var retryAfterDelay = result.Result.Headers.RetryAfter?.Delta;
|
||||
if (retryAfterDelay is not null)
|
||||
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||
}
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
|
||||
},
|
||||
(_, _, _, _) => Task.CompletedTask
|
||||
);
|
||||
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
|
||||
},
|
||||
(_, _, _, _) => Task.CompletedTask
|
||||
);
|
||||
|
||||
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) =>
|
||||
// This is extremely frail, but there's no other way
|
||||
Regex
|
||||
.Match(ex.Message, @": (\d+) \(")
|
||||
.Groups[1]
|
||||
.Value
|
||||
.NullIfWhiteSpace()?
|
||||
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
|
||||
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) =>
|
||||
// This is extremely frail, but there's no other way
|
||||
Regex
|
||||
.Match(ex.Message, @": (\d+) \(")
|
||||
.Groups[1]
|
||||
.Value
|
||||
.NullIfWhiteSpace()?
|
||||
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
|
||||
|
||||
public static IAsyncPolicy ExceptionPolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>() // dangerous
|
||||
.Or<HttpRequestException>(ex =>
|
||||
TryGetStatusCodeFromException(ex) is
|
||||
HttpStatusCode.TooManyRequests or
|
||||
HttpStatusCode.RequestTimeout or
|
||||
HttpStatusCode.InternalServerError
|
||||
)
|
||||
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||
}
|
||||
public static IAsyncPolicy ExceptionPolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>() // dangerous
|
||||
.Or<HttpRequestException>(ex =>
|
||||
TryGetStatusCodeFromException(ex) is
|
||||
HttpStatusCode.TooManyRequests or
|
||||
HttpStatusCode.RequestTimeout or
|
||||
HttpStatusCode.InternalServerError
|
||||
)
|
||||
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class PathEx
|
||||
{
|
||||
public static class PathEx
|
||||
public static StringBuilder EscapePath(StringBuilder pathBuffer)
|
||||
{
|
||||
public static StringBuilder EscapePath(StringBuilder pathBuffer)
|
||||
{
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
pathBuffer.Replace(invalidChar, '_');
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
pathBuffer.Replace(invalidChar, '_');
|
||||
|
||||
return pathBuffer;
|
||||
}
|
||||
|
||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||
return pathBuffer;
|
||||
}
|
||||
|
||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||
}
|
||||
@@ -4,42 +4,41 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public class UrlBuilder
|
||||
{
|
||||
public class UrlBuilder
|
||||
private string _path = "";
|
||||
|
||||
private readonly Dictionary<string, string?> _queryParameters = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public UrlBuilder SetPath(string path)
|
||||
{
|
||||
private string _path = "";
|
||||
_path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, string?> _queryParameters = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public UrlBuilder SetPath(string path)
|
||||
{
|
||||
_path = path;
|
||||
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true)
|
||||
{
|
||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||
return this;
|
||||
}
|
||||
|
||||
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true)
|
||||
{
|
||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||
return this;
|
||||
var keyEncoded = WebUtility.UrlEncode(key);
|
||||
var valueEncoded = WebUtility.UrlEncode(value);
|
||||
_queryParameters[keyEncoded] = valueEncoded;
|
||||
|
||||
var keyEncoded = WebUtility.UrlEncode(key);
|
||||
var valueEncoded = WebUtility.UrlEncode(value);
|
||||
_queryParameters[keyEncoded] = valueEncoded;
|
||||
return this;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
public string Build()
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
public string Build()
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
buffer.Append(_path);
|
||||
|
||||
buffer.Append(_path);
|
||||
if (_queryParameters.Any())
|
||||
buffer.Append('?').AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
if (_queryParameters.Any())
|
||||
buffer.Append('?').AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user