Compare commits
2 Commits
60ca21cacf
...
a732bc2b1e
Author | SHA1 | Date | |
---|---|---|---|
a732bc2b1e | |||
5b1a83d2ee |
@ -49,4 +49,33 @@ internal static class InputChannelExt {
|
||||
InputChannel.CrossLinkshell8 => 7,
|
||||
_ => uint.MaxValue,
|
||||
};
|
||||
|
||||
public static string Prefix(this InputChannel channel) => channel switch {
|
||||
InputChannel.Tell => "/tell",
|
||||
InputChannel.Say => "/say",
|
||||
InputChannel.Party => "/party",
|
||||
InputChannel.Alliance => "/alliance",
|
||||
InputChannel.Yell => "/yell",
|
||||
InputChannel.Shout => "/shout",
|
||||
InputChannel.FreeCompany => "/freecompany",
|
||||
InputChannel.PvpTeam => "/pvpteam",
|
||||
InputChannel.NoviceNetwork => "/novice",
|
||||
InputChannel.CrossLinkshell1 => "/cwlinkshell1",
|
||||
InputChannel.CrossLinkshell2 => "/cwlinkshell2",
|
||||
InputChannel.CrossLinkshell3 => "/cwlinkshell3",
|
||||
InputChannel.CrossLinkshell4 => "/cwlinkshell4",
|
||||
InputChannel.CrossLinkshell5 => "/cwlinkshell5",
|
||||
InputChannel.CrossLinkshell6 => "/cwlinkshell6",
|
||||
InputChannel.CrossLinkshell7 => "/cwlinkshell7",
|
||||
InputChannel.CrossLinkshell8 => "/cwlinkshell8",
|
||||
InputChannel.Linkshell1 => "/linkshell1",
|
||||
InputChannel.Linkshell2 => "/linkshell2",
|
||||
InputChannel.Linkshell3 => "/linkshell3",
|
||||
InputChannel.Linkshell4 => "/linkshell4",
|
||||
InputChannel.Linkshell5 => "/linkshell5",
|
||||
InputChannel.Linkshell6 => "/linkshell6",
|
||||
InputChannel.Linkshell7 => "/linkshell7",
|
||||
InputChannel.Linkshell8 => "/linkshell8",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ internal class Tab {
|
||||
public Dictionary<ChatType, ChatSource> ChatCodes = new();
|
||||
public bool DisplayUnread = true;
|
||||
public bool DisplayTimestamp = true;
|
||||
public InputChannel? Channel;
|
||||
|
||||
[NonSerialized]
|
||||
public uint Unread;
|
||||
@ -56,6 +57,7 @@ internal class Tab {
|
||||
ChatCodes = this.ChatCodes.ToDictionary(entry => entry.Key, entry => entry.Value),
|
||||
DisplayUnread = this.DisplayUnread,
|
||||
DisplayTimestamp = this.DisplayTimestamp,
|
||||
Channel = this.Channel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,9 @@ using Dalamud.Logging;
|
||||
using Dalamud.Memory;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||||
using FFXIVClientStructs.FFXIV.Client.System.String;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Agent;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
|
||||
using FFXIVClientStructs.FFXIV.Client.UI.Shell;
|
||||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||||
|
||||
@ -19,6 +21,17 @@ internal unsafe class GameFunctions : IDisposable {
|
||||
internal const string ChatLogRefresh = "40 53 56 57 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 49 8B F0 8B FA";
|
||||
internal const string ChangeChannelName = "E8 ?? ?? ?? ?? BA ?? ?? ?? ?? 48 8D 4D B0 48 8B F8 E8 ?? ?? ?? ?? 41 8B D6";
|
||||
internal const string ChangeChatChannel = "E8 ?? ?? ?? ?? 0F B7 44 37 ??";
|
||||
|
||||
// Context menu
|
||||
internal const string CurrentChatEntryOffset = "8B 77 ?? 8D 46 01 89 47 14 81 FE ?? ?? ?? ?? 72 03 FF 47";
|
||||
internal const string GetContentIdForChatEntry = "4C 8B 81 ?? ?? ?? ?? 4D 85 C0 74 17";
|
||||
|
||||
internal const string Indexer = "E8 ?? ?? ?? ?? 8B FD 8B CD";
|
||||
internal const string InviteToParty = "E8 ?? ?? ?? ?? 33 C0 EB 51";
|
||||
|
||||
internal const string FriendRequestBool = "40 53 48 83 EC 20 48 8B D9 48 8B 49 10 48 8B 01 FF 90 ?? ?? ?? ?? 48 8B 48 48";
|
||||
internal const string AgentContextYesNo = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 84 C0 74 3A";
|
||||
internal const string InviteToNoviceNetwork = "E8 ?? ?? ?? ?? E9 ?? ?? ?? ?? 48 8B CB E8 ?? ?? ?? ?? 45 33 C9";
|
||||
}
|
||||
|
||||
private delegate byte ChatLogRefreshDelegate(IntPtr log, ushort eventId, AtkValue* value);
|
||||
@ -27,6 +40,14 @@ internal unsafe class GameFunctions : IDisposable {
|
||||
|
||||
private delegate IntPtr ChangeChatChannelDelegate(RaptureShellModule* shell, int channel, uint linkshellIdx, Utf8String* tellTarget, byte one);
|
||||
|
||||
private delegate ulong GetContentIdForChatEntryDelegate(RaptureLogModule* log, uint index);
|
||||
|
||||
private delegate IntPtr InviteToPartyDelegate(IntPtr a1, ulong contentId, byte* playerName, ushort playerWorld);
|
||||
|
||||
private delegate IntPtr AgentContextYesNoDelegate(AgentInterface* context, uint a2, byte* playerName, ushort playerWorld, uint a5, byte a6);
|
||||
|
||||
private delegate byte InviteToNoviceNetworkDelegate(IntPtr a1, ulong contentId, ushort playerWorld, byte* playerName);
|
||||
|
||||
internal delegate void ChatActivatedEventDelegate(string? input);
|
||||
|
||||
private Plugin Plugin { get; }
|
||||
@ -34,6 +55,17 @@ internal unsafe class GameFunctions : IDisposable {
|
||||
private Hook<ChangeChannelNameDelegate>? ChangeChannelNameHook { get; }
|
||||
private readonly ChangeChatChannelDelegate? _changeChatChannel;
|
||||
|
||||
private readonly GetContentIdForChatEntryDelegate? _getContentIdForChatEntry;
|
||||
private readonly int? _currentChatEntryOffset;
|
||||
|
||||
private readonly delegate* unmanaged<IntPtr, uint, IntPtr> _indexer;
|
||||
private readonly InviteToPartyDelegate? _inviteToParty;
|
||||
|
||||
private readonly delegate* unmanaged<AgentInterface*, byte> _friendRequestBool;
|
||||
private readonly AgentContextYesNoDelegate? _agentContextYesNo;
|
||||
|
||||
private readonly InviteToNoviceNetworkDelegate? _inviteToNoviceNetwork;
|
||||
|
||||
internal event ChatActivatedEventDelegate? ChatActivated;
|
||||
|
||||
internal (InputChannel channel, List<Chunk> name) ChatChannel { get; private set; }
|
||||
@ -55,6 +87,34 @@ internal unsafe class GameFunctions : IDisposable {
|
||||
this._changeChatChannel = Marshal.GetDelegateForFunctionPointer<ChangeChatChannelDelegate>(changeChannelPtr);
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.CurrentChatEntryOffset, out var entryOffsetPtr)) {
|
||||
this._currentChatEntryOffset = *(byte*) (entryOffsetPtr + 2);
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.Indexer, out var indexerPtr)) {
|
||||
this._indexer = (delegate* unmanaged<IntPtr, uint, IntPtr>) indexerPtr;
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.GetContentIdForChatEntry, out var getContentIdPtr)) {
|
||||
this._getContentIdForChatEntry = Marshal.GetDelegateForFunctionPointer<GetContentIdForChatEntryDelegate>(getContentIdPtr);
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.InviteToParty, out var invitePtr)) {
|
||||
this._inviteToParty = Marshal.GetDelegateForFunctionPointer<InviteToPartyDelegate>(invitePtr);
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.FriendRequestBool, out var frBoolPtr)) {
|
||||
this._friendRequestBool = (delegate* unmanaged<AgentInterface*, byte>) frBoolPtr;
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.AgentContextYesNo, out var sendFriendRequestPtr)) {
|
||||
this._agentContextYesNo = Marshal.GetDelegateForFunctionPointer<AgentContextYesNoDelegate>(sendFriendRequestPtr);
|
||||
}
|
||||
|
||||
if (this.Plugin.SigScanner.TryScanText(Signatures.InviteToNoviceNetwork, out var nnPtr)) {
|
||||
this._inviteToNoviceNetwork = Marshal.GetDelegateForFunctionPointer<InviteToNoviceNetworkDelegate>(nnPtr);
|
||||
}
|
||||
|
||||
this.Plugin.ClientState.Login += this.Login;
|
||||
this.Login(null, null);
|
||||
}
|
||||
@ -79,6 +139,68 @@ internal unsafe class GameFunctions : IDisposable {
|
||||
this.ChangeChannelNameDetour((IntPtr) agent);
|
||||
}
|
||||
|
||||
internal uint? GetCurrentChatLogEntryIndex() {
|
||||
if (this._currentChatEntryOffset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var log = (IntPtr) Framework.Instance()->GetUiModule()->GetRaptureLogModule();
|
||||
return *(uint*) (log + this._currentChatEntryOffset.Value);
|
||||
}
|
||||
|
||||
internal ulong? GetContentIdForChatLogEntry(uint index) {
|
||||
return this._getContentIdForChatEntry?.Invoke(Framework.Instance()->GetUiModule()->GetRaptureLogModule(), index);
|
||||
}
|
||||
|
||||
internal void InviteToParty(string name, ushort world) {
|
||||
if (this._inviteToParty == null || this._indexer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var uiModule = Framework.Instance()->GetUiModule();
|
||||
// 6.05: 20D722
|
||||
var func = (delegate*<UIModule*, IntPtr>) uiModule->vfunc[33];
|
||||
var toIndex = func(uiModule);
|
||||
var a1 = this._indexer(toIndex, 1);
|
||||
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
// can specify content id if we have it, but there's no need
|
||||
this._inviteToParty(a1, 0, namePtr, world);
|
||||
}
|
||||
}
|
||||
|
||||
internal void SendFriendRequest(string name, ushort world) {
|
||||
if (this._agentContextYesNo == null || this._friendRequestBool == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var agent = Framework.Instance()->GetUiModule()->GetAgentModule()->GetAgentByInternalId(AgentId.Context);
|
||||
var a6 = this._friendRequestBool(agent);
|
||||
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
// 6.05: 20DA57
|
||||
this._agentContextYesNo(agent, 149, namePtr, world, 10_955, a6);
|
||||
}
|
||||
}
|
||||
|
||||
internal void InviteToNoviceNetwork(string name, ushort world) {
|
||||
if (this._inviteToNoviceNetwork == null || this._indexer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var uiModule = Framework.Instance()->GetUiModule();
|
||||
// 6.05: 20D722
|
||||
var func = (delegate*<UIModule*, IntPtr>) uiModule->vfunc[33];
|
||||
var toIndex = func(uiModule);
|
||||
// 6.05: 20E4CB
|
||||
var a1 = this._indexer(toIndex, 0x11);
|
||||
|
||||
fixed (byte* namePtr = name.ToTerminatedBytes()) {
|
||||
// can specify content id if we have it, but there's no need
|
||||
this._inviteToNoviceNetwork(a1, 0, world, namePtr);
|
||||
}
|
||||
}
|
||||
|
||||
internal void SetChatChannel(InputChannel channel, string? tellTarget = null) {
|
||||
if (this._changeChatChannel == null) {
|
||||
return;
|
||||
|
@ -3,6 +3,8 @@
|
||||
namespace ChatTwo;
|
||||
|
||||
internal class Message {
|
||||
internal ulong ContentId;
|
||||
|
||||
internal DateTime Date { get; }
|
||||
internal ChatCode Code { get; }
|
||||
internal List<Chunk> Sender { get; }
|
||||
|
@ -56,8 +56,6 @@ internal sealed class PayloadHandler {
|
||||
}
|
||||
|
||||
internal void Click(Chunk chunk, Payload payload, ImGuiMouseButton button) {
|
||||
PluginLog.Log($"clicked {payload} with {button}");
|
||||
|
||||
switch (button) {
|
||||
case ImGuiMouseButton.Left:
|
||||
this.LeftClickPayload(chunk, payload);
|
||||
@ -213,24 +211,50 @@ internal sealed class PayloadHandler {
|
||||
this.Log.Activate = true;
|
||||
}
|
||||
|
||||
if (ImGui.Selectable("Target")) {
|
||||
if (player.World.IsPublic && ImGui.Selectable("Invite to Party")) {
|
||||
// FIXME: don't show if player is in your party or if you're in their party
|
||||
// FIXME: don't show if in party and not leader
|
||||
this.Ui.Plugin.Functions.InviteToParty(player.PlayerName, (ushort) player.World.RowId);
|
||||
}
|
||||
|
||||
if (player.World.IsPublic && ImGui.Selectable("Send Friend Request")) {
|
||||
// FIXME: this shows window, clicking yes doesn't work
|
||||
// FIXME: only show if not already friend
|
||||
this.Ui.Plugin.Functions.SendFriendRequest(player.PlayerName, (ushort) player.World.RowId);
|
||||
}
|
||||
|
||||
if (player.World.IsPublic && ImGui.Selectable("Invite to Novice Network")) {
|
||||
// FIXME: only show if character is mentor and target is sprout/returner
|
||||
this.Ui.Plugin.Functions.InviteToNoviceNetwork(player.PlayerName, (ushort) player.World.RowId);
|
||||
}
|
||||
|
||||
if (ImGui.Selectable("Target") && this.FindCharacterForPayload(player) is { } obj) {
|
||||
this.Ui.Plugin.TargetManager.SetTarget(obj);
|
||||
}
|
||||
|
||||
// Add to Blacklist 0x1C
|
||||
// View Party Finder 0x2E
|
||||
// Reply in Selected Chat Mode 0x64
|
||||
}
|
||||
|
||||
private PlayerCharacter? FindCharacterForPayload(PlayerPayload payload) {
|
||||
foreach (var obj in this.Ui.Plugin.ObjectTable) {
|
||||
if (obj is not PlayerCharacter character) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character.Name.TextValue != player.PlayerName) {
|
||||
if (character.Name.TextValue != payload.PlayerName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (player.World.IsPublic && character.HomeWorld.Id != player.World.RowId) {
|
||||
if (payload.World.IsPublic && character.HomeWorld.Id != payload.World.RowId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.Ui.Plugin.TargetManager.SetTarget(obj);
|
||||
break;
|
||||
}
|
||||
return character;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PopupId(PlayerPayload player) {
|
||||
|
@ -1,5 +1,7 @@
|
||||
using ChatTwo.Code;
|
||||
using System.Collections.Concurrent;
|
||||
using ChatTwo.Code;
|
||||
using ChatTwo.Util;
|
||||
using Dalamud.Game;
|
||||
using Dalamud.Game.Text;
|
||||
using Dalamud.Game.Text.SeStringHandling;
|
||||
using Lumina.Excel.GeneratedSheets;
|
||||
@ -27,6 +29,7 @@ internal class Store : IDisposable {
|
||||
|
||||
private Mutex MessagesMutex { get; } = new();
|
||||
private List<Message> Messages { get; } = new();
|
||||
private ConcurrentQueue<(uint, Message)> Pending { get; } = new();
|
||||
|
||||
private Dictionary<ChatType, NameFormatting> Formats { get; } = new();
|
||||
|
||||
@ -34,14 +37,25 @@ internal class Store : IDisposable {
|
||||
this.Plugin = plugin;
|
||||
|
||||
this.Plugin.ChatGui.ChatMessageUnhandled += this.ChatMessage;
|
||||
this.Plugin.Framework.Update += this.GetMessageInfo;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
this.Plugin.Framework.Update -= this.GetMessageInfo;
|
||||
this.Plugin.ChatGui.ChatMessageUnhandled -= this.ChatMessage;
|
||||
|
||||
this.MessagesMutex.Dispose();
|
||||
}
|
||||
|
||||
private void GetMessageInfo(Framework framework) {
|
||||
if (!this.Pending.TryDequeue(out var entry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var contentId = this.Plugin.Functions.GetContentIdForChatLogEntry(entry.Item1);
|
||||
entry.Item2.ContentId = contentId ?? 0;
|
||||
}
|
||||
|
||||
internal MessagesLock GetMessages() {
|
||||
return new MessagesLock(this.Messages, this.MessagesMutex);
|
||||
}
|
||||
@ -97,7 +111,13 @@ internal class Store : IDisposable {
|
||||
|
||||
var messageChunks = ChunkUtil.ToChunks(message, chatCode.Type).ToList();
|
||||
|
||||
this.AddMessage(new Message(chatCode, senderChunks, messageChunks));
|
||||
var msg = new Message(chatCode, senderChunks, messageChunks);
|
||||
this.AddMessage(msg);
|
||||
|
||||
var idx = this.Plugin.Functions.GetCurrentChatLogEntryIndex();
|
||||
if (idx != null) {
|
||||
this.Pending.Enqueue((idx.Value - 1, msg));
|
||||
}
|
||||
}
|
||||
|
||||
internal class NameFormatting {
|
||||
|
@ -64,12 +64,14 @@ internal sealed class ChatLog : IUiComponent {
|
||||
|
||||
var lineHeight = ImGui.CalcTextSize("A").Y;
|
||||
|
||||
var currentTab = -1;
|
||||
if (ImGui.BeginTabBar("##chat2-tabs")) {
|
||||
for (var tabI = 0; tabI < this.Ui.Plugin.Config.Tabs.Count; tabI++) {
|
||||
var tab = this.Ui.Plugin.Config.Tabs[tabI];
|
||||
|
||||
var unread = tabI == this._lastTab || !tab.DisplayUnread || tab.Unread == 0 ? "" : $" ({tab.Unread})";
|
||||
if (ImGui.BeginTabItem($"{tab.Name}{unread}###log-tab-{tabI}")) {
|
||||
currentTab = tabI;
|
||||
var switchedTab = this._lastTab != tabI;
|
||||
this._lastTab = tabI;
|
||||
tab.Unread = 0;
|
||||
@ -147,19 +149,34 @@ internal sealed class ChatLog : IUiComponent {
|
||||
ImGui.SetKeyboardFocusHere();
|
||||
}
|
||||
|
||||
Tab? activeTab = null;
|
||||
if (currentTab > -1) {
|
||||
activeTab = this.Ui.Plugin.Config.Tabs[currentTab];
|
||||
}
|
||||
|
||||
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
|
||||
try {
|
||||
if (activeTab is { Channel: { } channel }) {
|
||||
ImGui.TextUnformatted(channel.ToChatType().Name());
|
||||
} else {
|
||||
this.DrawChunks(this.Ui.Plugin.Functions.ChatChannel.name);
|
||||
}
|
||||
} finally {
|
||||
ImGui.PopStyleVar();
|
||||
}
|
||||
|
||||
var beforeIcon = ImGui.GetCursorPos();
|
||||
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment)) {
|
||||
if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment) && activeTab is not { Channel: { } }) {
|
||||
ImGui.OpenPopup(ChatChannelPicker);
|
||||
}
|
||||
|
||||
if (activeTab is { Channel: { } } && ImGui.IsItemHovered()) {
|
||||
ImGui.BeginTooltip();
|
||||
ImGui.TextUnformatted("Disabled for this tab.");
|
||||
ImGui.EndTooltip();
|
||||
}
|
||||
|
||||
if (ImGui.BeginPopup(ChatChannelPicker)) {
|
||||
foreach (var channel in Enum.GetValues<InputChannel>()) {
|
||||
var name = this.Ui.Plugin.DataManager.GetExcelSheet<LogFilter>()!
|
||||
@ -200,6 +217,10 @@ internal sealed class ChatLog : IUiComponent {
|
||||
this.AddBacklog(trimmed);
|
||||
this._inputBacklogIdx = -1;
|
||||
|
||||
if (activeTab is { Channel: { } channel } && !trimmed.StartsWith('/')) {
|
||||
trimmed = $"{channel.Prefix()} {trimmed}";
|
||||
}
|
||||
|
||||
this.Ui.Plugin.Common.Functions.Chat.SendMessage(trimmed);
|
||||
}
|
||||
|
||||
|
@ -97,6 +97,21 @@ internal sealed class Settings : IUiComponent {
|
||||
ImGui.Checkbox("Show unread count", ref tab.DisplayUnread);
|
||||
ImGui.Checkbox("Show timestamps", ref tab.DisplayTimestamp);
|
||||
|
||||
var input = tab.Channel?.ToChatType().Name() ?? "<None>";
|
||||
if (ImGui.BeginCombo("Input channel", input)) {
|
||||
if (ImGui.Selectable("<None>", tab.Channel == null)) {
|
||||
tab.Channel = null;
|
||||
}
|
||||
|
||||
foreach (var channel in Enum.GetValues<InputChannel>()) {
|
||||
if (ImGui.Selectable(channel.ToChatType().Name() ?? "???", tab.Channel == channel)) {
|
||||
tab.Channel = channel;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.EndCombo();
|
||||
}
|
||||
|
||||
if (ImGui.TreeNodeEx("Channels")) {
|
||||
foreach (var type in Enum.GetValues<ChatType>()) {
|
||||
var enabled = tab.ChatCodes.ContainsKey(type);
|
||||
|
13
ChatTwo/Util/StringUtil.cs
Executable file
13
ChatTwo/Util/StringUtil.cs
Executable file
@ -0,0 +1,13 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ChatTwo.Util;
|
||||
|
||||
internal static class StringUtil {
|
||||
internal static byte[] ToTerminatedBytes(this string s) {
|
||||
var unterminated = Encoding.UTF8.GetBytes(s);
|
||||
var bytes = new byte[unterminated.Length + 1];
|
||||
Array.Copy(unterminated, bytes, unterminated.Length);
|
||||
bytes[^1] = 0;
|
||||
return bytes;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user