mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-02-16 14:38:19 +00:00
More refactoring
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using Tyrrrz.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
@@ -36,37 +37,35 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
var options = new ExportOptions(baseFilePath, format, partitionLimit);
|
||||
|
||||
// Context
|
||||
var mentionableUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
||||
var mentionableChannels = await _discord.GetGuildChannelsAsync(guild.Id);
|
||||
var mentionableRoles = guild.Roles;
|
||||
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
||||
var contextChannels = await _discord.GetGuildChannelsAsync(guild.Id);
|
||||
var contextRoles = await _discord.GetGuildRolesAsync(guild.Id);
|
||||
|
||||
var context = new ExportContext(
|
||||
guild, channel, after, before, dateFormat,
|
||||
mentionableUsers, mentionableChannels, mentionableRoles
|
||||
contextMembers, contextChannels, contextRoles
|
||||
);
|
||||
|
||||
await using var messageExporter = new MessageExporter(options, context);
|
||||
|
||||
var exportedAnything = false;
|
||||
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
||||
await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress))
|
||||
{
|
||||
// Add encountered users to the list of mentionable users
|
||||
var encounteredUsers = new List<User>();
|
||||
encounteredUsers.Add(message.Author);
|
||||
encounteredUsers.AddRange(message.MentionedUsers);
|
||||
|
||||
mentionableUsers.AddRange(encounteredUsers);
|
||||
|
||||
foreach (User u in encounteredUsers)
|
||||
// Resolve members for referenced users
|
||||
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
||||
{
|
||||
if (!guild.Members.ContainsKey(u.Id))
|
||||
if (encounteredUsers.Add(referencedUser))
|
||||
{
|
||||
var member = await _discord.GetGuildMemberAsync(guild.Id, u.Id);
|
||||
guild.Members[u.Id] = member;
|
||||
var member =
|
||||
await _discord.TryGetGuildMemberAsync(guild.Id, referencedUser) ??
|
||||
Member.CreateForUser(referencedUser);
|
||||
|
||||
contextMembers.Add(member);
|
||||
}
|
||||
}
|
||||
|
||||
// Render message
|
||||
// Export message
|
||||
await messageExporter.ExportMessageAsync(message);
|
||||
exportedAnything = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
@@ -16,11 +18,11 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
public string DateFormat { get; }
|
||||
|
||||
public IReadOnlyCollection<User> MentionableUsers { get; }
|
||||
public IReadOnlyCollection<Member> Members { get; }
|
||||
|
||||
public IReadOnlyCollection<Channel> MentionableChannels { get; }
|
||||
public IReadOnlyCollection<Channel> Channels { get; }
|
||||
|
||||
public IReadOnlyCollection<Role> MentionableRoles { get; }
|
||||
public IReadOnlyCollection<Role> Roles { get; }
|
||||
|
||||
public ExportContext(
|
||||
Guild guild,
|
||||
@@ -28,19 +30,41 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
DateTimeOffset? after,
|
||||
DateTimeOffset? before,
|
||||
string dateFormat,
|
||||
IReadOnlyCollection<User> mentionableUsers,
|
||||
IReadOnlyCollection<Channel> mentionableChannels,
|
||||
IReadOnlyCollection<Role> mentionableRoles)
|
||||
|
||||
IReadOnlyCollection<Member> members,
|
||||
IReadOnlyCollection<Channel> channels,
|
||||
IReadOnlyCollection<Role> roles)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
After = after;
|
||||
Before = before;
|
||||
DateFormat = dateFormat;
|
||||
MentionableUsers = mentionableUsers;
|
||||
MentionableChannels = mentionableChannels;
|
||||
MentionableRoles = mentionableRoles;
|
||||
Members = members;
|
||||
Channels = channels;
|
||||
Roles = roles;
|
||||
}
|
||||
|
||||
public Member? TryGetMentionedMember(string id) =>
|
||||
Members.FirstOrDefault(m => m.Id == id);
|
||||
|
||||
public Channel? TryGetMentionedChannel(string id) =>
|
||||
Channels.FirstOrDefault(c => c.Id == id);
|
||||
|
||||
public Role? TryGetMentionedRole(string id) =>
|
||||
Roles.FirstOrDefault(r => r.Id == id);
|
||||
|
||||
public Member? TryGetUserMember(User user) => Members
|
||||
.FirstOrDefault(m => m.Id == user.Id);
|
||||
|
||||
public Color? TryGetUserColor(User user)
|
||||
{
|
||||
var member = TryGetUserMember(user);
|
||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||
|
||||
return roles?
|
||||
.OrderByDescending(r => r.Position)
|
||||
.Select(r => r.Color)
|
||||
.FirstOrDefault(c => c != null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ img {
|
||||
}
|
||||
|
||||
.markdown {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
@@ -62,22 +62,22 @@
|
||||
</div>
|
||||
<div class="preamble__entries-container">
|
||||
<div class="preamble__entry">{{ Context.Guild.Name | html.escape }}</div>
|
||||
<div class="preamble__entry">{{ Context.Channel.Name | html.escape }}</div>
|
||||
<div class="preamble__entry">{{ Context.Channel.Category | html.escape }} / {{ Context.Channel.Name | html.escape }}</div>
|
||||
|
||||
{{~ if Context.Channel.Topic ~}}
|
||||
<div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div>
|
||||
<div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if Context.After || Context.Before ~}}
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
{{~ if Context.After && Context.Before ~}}
|
||||
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
|
||||
{{~ else if Context.After ~}}
|
||||
After {{ Context.After | FormatDate | html.escape }}
|
||||
{{~ else if Context.Before ~}}
|
||||
Before {{ Context.Before | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
{{~ if Context.After && Context.Before ~}}
|
||||
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
|
||||
{{~ else if Context.After ~}}
|
||||
After {{ Context.After | FormatDate | html.escape }}
|
||||
{{~ else if Context.Before ~}}
|
||||
Before {{ Context.Before | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,27 +5,30 @@
|
||||
</div>
|
||||
<div class="chatlog__messages">
|
||||
{{~ # Author name and timestamp ~}}
|
||||
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if GetUserColor Context.Guild MessageGroup.Author }} style="color: {{ GetUserColor Context.Guild MessageGroup.Author }}" {{ end }}>{{ GetUserNick Context.Guild MessageGroup.Author | html.escape }}</span>
|
||||
{{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
|
||||
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
|
||||
|
||||
{{~ # Bot tag ~}}
|
||||
{{~ if MessageGroup.Author.IsBot ~}}
|
||||
<span class="chatlog__bot-tag">BOT</span>
|
||||
<span class="chatlog__bot-tag">BOT</span>
|
||||
{{~ end ~}}
|
||||
|
||||
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
|
||||
|
||||
{{~ # Messages ~}}
|
||||
{{~ for message in MessageGroup.Messages ~}}
|
||||
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
|
||||
<div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
|
||||
{{~ # Content ~}}
|
||||
{{~ if message.Content ~}}
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">{{ message.Content | FormatMarkdown }}</div>
|
||||
<div class="markdown">
|
||||
{{- message.Content | FormatMarkdown -}}
|
||||
|
||||
{{~ # Edited timestamp ~}}
|
||||
{{~ if message.EditedTimestamp ~}}
|
||||
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
|
||||
{{~ end ~}}
|
||||
{{- # Edited timestamp -}}
|
||||
{{- if message.EditedTimestamp -}}
|
||||
{{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
@@ -89,16 +92,16 @@
|
||||
{{~ if embed.Title ~}}
|
||||
<div class="chatlog__embed-title">
|
||||
{{~ if embed.Url ~}}
|
||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
|
||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
|
||||
{{~ else ~}}
|
||||
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
|
||||
<div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Description ~}}
|
||||
{{~ if embed.Description ~}}
|
||||
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
|
||||
<div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Fields ~}}
|
||||
@@ -107,10 +110,10 @@
|
||||
{{~ for field in embed.Fields ~}}
|
||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||
{{~ if field.Name ~}}
|
||||
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
|
||||
<div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
{{~ if field.Value ~}}
|
||||
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
|
||||
<div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -73,12 +74,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
scriptObject.Import("FormatDate",
|
||||
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat)));
|
||||
|
||||
scriptObject.Import("FormatColorRgb",
|
||||
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
|
||||
|
||||
scriptObject.Import("TryGetUserColor",
|
||||
new Func<User, Color?>(Context.TryGetUserColor));
|
||||
|
||||
scriptObject.Import("TryGetUserNick",
|
||||
new Func<User, string?>(u => Context.TryGetUserMember(u)?.Nick));
|
||||
|
||||
scriptObject.Import("FormatMarkdown",
|
||||
new Func<string?, string>(FormatMarkdown));
|
||||
new Func<string?, string>(m => FormatMarkdown(m)));
|
||||
|
||||
scriptObject.Import("GetUserColor", new Func<Guild, User, string>(Guild.GetUserColor));
|
||||
|
||||
scriptObject.Import("GetUserNick", new Func<Guild, User, string>(Guild.GetUserNick));
|
||||
scriptObject.Import("FormatEmbedMarkdown",
|
||||
new Func<string?, string>(m => FormatMarkdown(m, false)));
|
||||
|
||||
// Push model
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
@@ -89,8 +98,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
return templateContext;
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string? markdown) =>
|
||||
HtmlMarkdownVisitor.Format(Context, markdown ?? "");
|
||||
private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
|
||||
|
||||
private async Task RenderCurrentMessageGroupAsync()
|
||||
{
|
||||
|
||||
@@ -84,8 +84,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("name", embedField.Name);
|
||||
_writer.WriteString("value", embedField.Value);
|
||||
_writer.WriteString("name", FormatMarkdown(embedField.Name));
|
||||
_writer.WriteString("value", FormatMarkdown(embedField.Value));
|
||||
_writer.WriteBoolean("isInline", embedField.IsInline);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -156,6 +156,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject("channel");
|
||||
_writer.WriteString("id", Context.Channel.Id);
|
||||
_writer.WriteString("type", Context.Channel.Type.ToString());
|
||||
_writer.WriteString("category", Context.Channel.Category);
|
||||
_writer.WriteString("name", Context.Channel.Name);
|
||||
_writer.WriteString("topic", Context.Channel.Topic);
|
||||
_writer.WriteEndObject();
|
||||
|
||||
@@ -85,38 +85,38 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
{
|
||||
var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ??
|
||||
User.CreateUnknownUser(mention.Id);
|
||||
|
||||
var nick = Guild.GetUserNick(_context.Guild, user);
|
||||
var member = _context.TryGetMentionedMember(mention.Id);
|
||||
var fullName = member?.User.FullName ?? "Unknown";
|
||||
var nick = member?.Nick ?? "Unknown";
|
||||
|
||||
_buffer
|
||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">")
|
||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
||||
.Append("@").Append(HtmlEncode(nick))
|
||||
.Append("</span>");
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ??
|
||||
Channel.CreateDeletedChannel(mention.Id);
|
||||
var channel = _context.TryGetMentionedChannel(mention.Id);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer
|
||||
.Append("<span class=\"mention\">")
|
||||
.Append("#").Append(HtmlEncode(channel.Name))
|
||||
.Append("#").Append(HtmlEncode(name))
|
||||
.Append("</span>");
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ??
|
||||
Role.CreateDeletedRole(mention.Id);
|
||||
var role = _context.TryGetMentionedRole(mention.Id);
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
var color = role?.Color;
|
||||
|
||||
var style = role.Color != null
|
||||
? $"color: {role.Color.Value.ToHexString()}; background-color: rgba({role.Color.Value.ToRgbString()}, 0.1);"
|
||||
var style = color != null
|
||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
||||
: "";
|
||||
|
||||
_buffer
|
||||
.Append($"<span class=\"mention\" style=\"{style}\">")
|
||||
.Append("@").Append(HtmlEncode(role.Name))
|
||||
.Append("@").Append(HtmlEncode(name))
|
||||
.Append("</span>");
|
||||
}
|
||||
|
||||
@@ -162,10 +162,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
{
|
||||
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
||||
|
||||
public static string Format(ExportContext context, string markdown)
|
||||
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
|
||||
{
|
||||
var nodes = MarkdownParser.Parse(markdown);
|
||||
var isJumbo = nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
|
||||
|
||||
var isJumbo =
|
||||
isJumboAllowed &&
|
||||
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
|
||||
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Markdown;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
|
||||
@@ -27,24 +25,24 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
{
|
||||
if (mention.Type == MentionType.User)
|
||||
{
|
||||
var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ??
|
||||
User.CreateUnknownUser(mention.Id);
|
||||
var member = _context.TryGetMentionedMember(mention.Id);
|
||||
var name = member?.User.Name ?? "Unknown";
|
||||
|
||||
_buffer.Append($"@{user.Name}");
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ??
|
||||
Channel.CreateDeletedChannel(mention.Id);
|
||||
var channel = _context.TryGetMentionedChannel(mention.Id);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer.Append($"#{channel.Name}");
|
||||
_buffer.Append($"#{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ??
|
||||
Role.CreateDeletedRole(mention.Id);
|
||||
var role = _context.TryGetMentionedRole(mention.Id);
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
|
||||
_buffer.Append($"@{role.Name}");
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
|
||||
return base.VisitMention(mention);
|
||||
|
||||
@@ -76,8 +76,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
foreach (var field in embed.Fields)
|
||||
{
|
||||
buffer
|
||||
.AppendLineIfNotNullOrWhiteSpace(field.Name)
|
||||
.AppendLineIfNotNullOrWhiteSpace(field.Value);
|
||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Name))
|
||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Value));
|
||||
}
|
||||
|
||||
buffer
|
||||
@@ -135,7 +135,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
buffer.Append('=', 62).AppendLine();
|
||||
buffer.AppendLine($"Guild: {Context.Guild.Name}");
|
||||
buffer.AppendLine($"Channel: {Context.Channel.Name}");
|
||||
buffer.AppendLine($"Channel: {Context.Channel.Category} / {Context.Channel.Name}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Context.Channel.Topic))
|
||||
buffer.AppendLine($"Topic: {Context.Channel.Topic}");
|
||||
|
||||
Reference in New Issue
Block a user