mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: enhance profile editing with avatar support and UI adjustments
- Added AvatarPath to ProfileEditResult for user profile updates. - Updated ProfileEditDialog to include avatar selection with a browse button. - Increased dialog height to accommodate new avatar input fields. - Implemented avatar file path handling in the profile edit dialog. feat: introduce moderation features and user roles - Added ServerRole enum to define user roles (Member, Mod, Admin, Owner). - Extended User model to include role, mute, and ban status. - Created ModerationController for user role assignment, kicking, banning, and muting. - Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions. - Updated database schema with new columns for user roles and moderation states. fix: ensure muted users cannot send messages - Added mute status checks in ChatService to prevent message sending for muted users. - Updated user presence and status handling to reflect role changes and moderation actions. chore: update constants for ASCII art rendering - Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
This commit is contained in:
@@ -76,6 +76,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
_mainWindow.OnThemeSelected += HandleThemeSelected;
|
||||||
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||||
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
||||||
|
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -292,6 +293,56 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnKickUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.KickUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnBanUser += async (username, reason) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.BanUserAsync(username, reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnbanUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnbanUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnMuteUser += async (username, duration) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.MuteUserAsync(username, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnUnmuteUser += async (username) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
await _apiClient!.UnmuteUserAsync(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnAssignRole += async (username, roleStr) =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var role = roleStr switch
|
||||||
|
{
|
||||||
|
"admin" => ServerRole.Admin,
|
||||||
|
"mod" => ServerRole.Mod,
|
||||||
|
_ => ServerRole.Member,
|
||||||
|
};
|
||||||
|
await _apiClient!.AssignRoleAsync(username, role);
|
||||||
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnNukeChannel += async () =>
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated) return;
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel)) return;
|
||||||
|
await _apiClient!.NukeChannelAsync(channel);
|
||||||
|
};
|
||||||
|
|
||||||
_commandHandler.OnQuit += () =>
|
_commandHandler.OnQuit += () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _app.RequestStop());
|
InvokeUI(() => _app.RequestStop());
|
||||||
@@ -361,6 +412,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
SaveServerToConfig(result);
|
SaveServerToConfig(result);
|
||||||
}, "Connection failed", "Connect");
|
}, "Connection failed", "Connect");
|
||||||
}
|
}
|
||||||
@@ -440,6 +492,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
// History might not be available
|
// History might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
}, "Failed to join channel");
|
}, "Failed to join channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +580,45 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload avatar if specified
|
||||||
|
if (editResult.AvatarPath is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(editResult.AvatarPath, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var bytes = await http.GetByteArrayAsync(uri);
|
||||||
|
stream = new MemoryStream(bytes);
|
||||||
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
fileName = "avatar.png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stream = File.OpenRead(editResult.AvatarPath);
|
||||||
|
fileName = Path.GetFileName(editResult.AvatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
await _apiClient!.UploadAvatarAsync(stream, fileName);
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(channel))
|
||||||
|
InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Avatar upload failed for {Target}", editResult.AvatarPath);
|
||||||
|
InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_config.DefaultPreset = new AccountPreset
|
_config.DefaultPreset = new AccountPreset
|
||||||
{
|
{
|
||||||
DisplayName = editResult.DisplayName,
|
DisplayName = editResult.DisplayName,
|
||||||
@@ -617,6 +710,47 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
}, "Failed to create channel");
|
}, "Failed to create channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void HandleDeleteChannelRequested()
|
||||||
|
{
|
||||||
|
if (!IsAuthenticated || !IsConnected)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("Not connected to a server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel))
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("No channel selected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel == HubConstants.DefaultChannel)
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError($"The #{HubConstants.DefaultChannel} channel cannot be deleted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirm = MessageBox.Query(_app, "Delete Channel",
|
||||||
|
$"Are you sure you want to delete #{channel}?\nThis will remove all messages permanently.", "Delete", "Cancel");
|
||||||
|
|
||||||
|
if (confirm != 0) return;
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
await _apiClient!.DeleteChannelAsync(channel);
|
||||||
|
_joinedChannels.Remove(channel);
|
||||||
|
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.SetChannels(channels);
|
||||||
|
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
|
||||||
|
_mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
|
||||||
|
});
|
||||||
|
}, "Failed to delete channel");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Connection Event Wiring ────────────────────────────────────────────
|
// ── Connection Event Wiring ────────────────────────────────────────────
|
||||||
|
|
||||||
private void WireConnectionEvents(EchoHubConnection connection)
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
@@ -625,10 +759,18 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _mainWindow.AddMessage(message));
|
InvokeUI(() => _mainWindow.AddMessage(message));
|
||||||
|
|
||||||
connection.OnUserJoined += (channelName, username) =>
|
connection.OnUserJoined += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserLeft += (channelName, username) =>
|
connection.OnUserLeft += (channelName, username) =>
|
||||||
|
{
|
||||||
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
|
||||||
|
if (channelName == _mainWindow.CurrentChannel)
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
connection.OnUserStatusChanged += presence =>
|
connection.OnUserStatusChanged += presence =>
|
||||||
{
|
{
|
||||||
@@ -642,6 +784,49 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
foreach (var channelName in _mainWindow.GetChannelNames())
|
foreach (var channelName in _mainWindow.GetChannelNames())
|
||||||
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
|
||||||
});
|
});
|
||||||
|
FetchAndUpdateOnlineUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserKicked += (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" ({reason})" : "";
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}");
|
||||||
|
if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_mainWindow.AddSystemMessage(channelName, "You were kicked from this channel.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnUserBanned += (username, reason) =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
if (username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_mainWindow.ShowError("You have been banned from this server.");
|
||||||
|
HandleDisconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnMessageDeleted += (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.RemoveMessage(channelName, messageId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.OnChannelNuked += channelName =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_mainWindow.ClearChannelMessages(channelName);
|
||||||
|
_mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.OnError += errorMessage =>
|
connection.OnError += errorMessage =>
|
||||||
@@ -673,6 +858,25 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
// ── Private Helpers ────────────────────────────────────────────────────
|
// ── Private Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void FetchAndUpdateOnlineUsers()
|
||||||
|
{
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
if (string.IsNullOrEmpty(channel) || !IsConnected) return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var users = await _connection!.GetOnlineUsersAsync(channel);
|
||||||
|
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Debug(ex, "Failed to fetch online users for {Channel}", channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveServerToConfig(ConnectDialogResult result)
|
private void SaveServerToConfig(ConnectDialogResult result)
|
||||||
{
|
{
|
||||||
var savedServer = new SavedServer
|
var savedServer = new SavedServer
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnSetTopic;
|
public event Func<string, Task>? OnSetTopic;
|
||||||
public event Func<Task>? OnListUsers;
|
public event Func<Task>? OnListUsers;
|
||||||
public event Func<string, Task>? OnSetAvatar;
|
public event Func<string, Task>? OnSetAvatar;
|
||||||
|
public event Func<string, string?, Task>? OnKickUser;
|
||||||
|
public event Func<string, string?, Task>? OnBanUser;
|
||||||
|
public event Func<string, Task>? OnUnbanUser;
|
||||||
|
public event Func<string, int?, Task>? OnMuteUser;
|
||||||
|
public event Func<string, Task>? OnUnmuteUser;
|
||||||
|
public event Func<string, string, Task>? OnAssignRole;
|
||||||
|
public event Func<Task>? OnNukeChannel;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -46,6 +53,13 @@ public class CommandHandler
|
|||||||
"leave" => await HandleLeave(),
|
"leave" => await HandleLeave(),
|
||||||
"topic" => await HandleTopic(args),
|
"topic" => await HandleTopic(args),
|
||||||
"users" => await HandleUsers(),
|
"users" => await HandleUsers(),
|
||||||
|
"kick" => await HandleKick(args),
|
||||||
|
"ban" => await HandleBan(args),
|
||||||
|
"unban" => await HandleUnban(args),
|
||||||
|
"mute" => await HandleMute(args),
|
||||||
|
"unmute" => await HandleUnmute(args),
|
||||||
|
"role" => await HandleRole(args),
|
||||||
|
"nuke" => await HandleNuke(),
|
||||||
"quit" or "exit" => await HandleQuit(),
|
"quit" or "exit" => await HandleQuit(),
|
||||||
"help" or "?" => await HandleHelp(),
|
"help" or "?" => await HandleHelp(),
|
||||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
@@ -212,6 +226,95 @@ public class CommandHandler
|
|||||||
return new CommandResult(true);
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleKick(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /kick <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnKickUser is not null)
|
||||||
|
await OnKickUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Kicking {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBan(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /ban <username> [reason]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
var reason = parts.Length > 1 ? parts[1] : null;
|
||||||
|
|
||||||
|
if (OnBanUser is not null)
|
||||||
|
await OnBanUser(username, reason);
|
||||||
|
return new CommandResult(true, $"Banning {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnban(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unban <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnbanUser is not null)
|
||||||
|
await OnUnbanUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unbanning {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /mute <username> [duration_minutes]", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
var username = parts[0];
|
||||||
|
int? duration = parts.Length > 1 && int.TryParse(parts[1], out var d) ? d : null;
|
||||||
|
|
||||||
|
if (OnMuteUser is not null)
|
||||||
|
await OnMuteUser(username, duration);
|
||||||
|
return new CommandResult(true, $"Muting {username}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleUnmute(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /unmute <username>", IsError: true);
|
||||||
|
|
||||||
|
if (OnUnmuteUser is not null)
|
||||||
|
await OnUnmuteUser(args.Trim());
|
||||||
|
return new CommandResult(true, $"Unmuting {args.Trim()}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleRole(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var parts = args.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return new CommandResult(true, "Usage: /role <username> <admin|mod|member>", IsError: true);
|
||||||
|
|
||||||
|
var username = parts[0];
|
||||||
|
var role = parts[1].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (role is not ("admin" or "mod" or "member"))
|
||||||
|
return new CommandResult(true, "Invalid role. Use: admin, mod, or member", IsError: true);
|
||||||
|
|
||||||
|
if (OnAssignRole is not null)
|
||||||
|
await OnAssignRole(username, role);
|
||||||
|
return new CommandResult(true, $"Setting {username} to {role}...");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleNuke()
|
||||||
|
{
|
||||||
|
if (OnNukeChannel is not null)
|
||||||
|
await OnNukeChannel();
|
||||||
|
return new CommandResult(true, "Nuking channel history...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleHelp()
|
private async Task<CommandResult> HandleHelp()
|
||||||
{
|
{
|
||||||
if (OnHelp is not null)
|
if (OnHelp is not null)
|
||||||
@@ -225,12 +328,20 @@ public class CommandHandler
|
|||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
/send <filepath or URL> - Send a file or image
|
/send <filepath or URL> - Send a file or image
|
||||||
/avatar <URL or filepath> - Set your avatar
|
/avatar <URL or filepath> - Set your avatar
|
||||||
/profile [username] - View a profile (yours if no name given)
|
/profile [username] - View a profile
|
||||||
/servers - Open saved servers
|
/servers - Open saved servers
|
||||||
/join <channel> - Join a channel
|
/join <channel> - Join a channel
|
||||||
/leave - Leave current channel
|
/leave - Leave current channel
|
||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
|
Moderation:
|
||||||
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
|
/unban <user> - Unban a user (Admin+)
|
||||||
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
|
/nuke - Clear channel history (Mod+)
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.Services;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
@@ -211,6 +212,72 @@ public sealed class ApiClient : IDisposable
|
|||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync("/api/moderation/role", new AssignRoleRequest(username, role)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task KickUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/kick/{Uri.EscapeDataString(username)}", new KickRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BanUserAsync(string username, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/ban/{Uri.EscapeDataString(username)}", new BanRequest(reason)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnbanUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unban/{Uri.EscapeDataString(username)}", new {}));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MuteUserAsync(string username, int? durationMinutes = null, string? reason = null)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/mute/{Uri.EscapeDataString(username)}", new MuteRequest(reason, durationMinutes)));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UnmuteUserAsync(string username)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.PostAsJsonAsync($"/api/moderation/unmute/{Uri.EscapeDataString(username)}", new {}));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteMessageAsync(Guid messageId)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/messages/{messageId}"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NukeChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedRequestAsync(() =>
|
||||||
|
_http.DeleteAsync($"/api/moderation/channels/{Uri.EscapeDataString(channelName)}/nuke"));
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
}
|
||||||
|
|
||||||
private void SetTokens(LoginResponse result)
|
private void SetTokens(LoginResponse result)
|
||||||
{
|
{
|
||||||
_accessToken = result.Token;
|
_accessToken = result.Token;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
public event Action<string, string>? OnUserLeft;
|
public event Action<string, string>? OnUserLeft;
|
||||||
public event Action<ChannelDto>? OnChannelUpdated;
|
public event Action<ChannelDto>? OnChannelUpdated;
|
||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
|
public event Action<string, string?>? OnUserBanned;
|
||||||
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelNuked;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
public event Action<string>? OnConnectionStateChanged;
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
public event Action? OnReconnected;
|
public event Action? OnReconnected;
|
||||||
@@ -81,6 +85,26 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnUserStatusChanged?.Invoke(presence);
|
OnUserStatusChanged?.Invoke(presence);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string, string?>(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserKicked?.Invoke(channelName, username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, string?>(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
|
||||||
|
{
|
||||||
|
OnUserBanned?.Invoke(username, reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string, Guid>(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
|
||||||
|
{
|
||||||
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelNuked?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
|
||||||
{
|
{
|
||||||
OnError?.Invoke(message);
|
OnError?.Invoke(message);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
public List<ChatSegment> Segments { get; }
|
public List<ChatSegment> Segments { get; }
|
||||||
public int TextLength { get; }
|
public int TextLength { get; }
|
||||||
|
public Guid? MessageId { get; set; }
|
||||||
|
public bool IsMention { get; set; }
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
public ChatLine(string plainText)
|
||||||
{
|
{
|
||||||
@@ -89,14 +91,25 @@ public partial class ChatLine
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
||||||
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
|
/// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
||||||
{
|
{
|
||||||
var segments = new List<ChatSegment>();
|
var segments = new List<ChatSegment>();
|
||||||
var regex = AnsiColorRegex();
|
var regex = AnsiColorRegex();
|
||||||
int lastIndex = 0;
|
int lastIndex = 0;
|
||||||
Attribute? currentColor = defaultAttr;
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.Black;
|
||||||
|
|
||||||
|
Attribute? BuildAttr()
|
||||||
|
{
|
||||||
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
|
var bg = currentBg ?? defaultBg;
|
||||||
|
return new Attribute(fg, bg);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (Match match in regex.Matches(ansiText))
|
foreach (Match match in regex.Matches(ansiText))
|
||||||
{
|
{
|
||||||
@@ -105,22 +118,26 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
var text = ansiText[lastIndex..match.Index];
|
var text = ansiText[lastIndex..match.Index];
|
||||||
if (text.Length > 0)
|
if (text.Length > 0)
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
segments.Add(new ChatSegment(text, BuildAttr()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the escape sequence
|
// Parse the escape sequence
|
||||||
if (match.Groups[1].Value == "0")
|
if (match.Groups[1].Value == "0")
|
||||||
{
|
{
|
||||||
// Reset
|
// Reset
|
||||||
currentColor = defaultAttr;
|
currentFg = null;
|
||||||
|
currentBg = null;
|
||||||
}
|
}
|
||||||
else if (match.Groups[2].Success)
|
else if (match.Groups[2].Success)
|
||||||
{
|
{
|
||||||
// 38;2;R;G;B — 24-bit foreground color
|
|
||||||
var r = int.Parse(match.Groups[3].Value);
|
var r = int.Parse(match.Groups[3].Value);
|
||||||
var g = int.Parse(match.Groups[4].Value);
|
var g = int.Parse(match.Groups[4].Value);
|
||||||
var b = int.Parse(match.Groups[5].Value);
|
var b = int.Parse(match.Groups[5].Value);
|
||||||
currentColor = new Attribute(new Color(r, g, b), Color.Black);
|
|
||||||
|
if (match.Groups[2].Value == "38;2")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else // 48;2
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
lastIndex = match.Index + match.Length;
|
||||||
@@ -131,14 +148,14 @@ public partial class ChatLine
|
|||||||
{
|
{
|
||||||
var text = ansiText[lastIndex..];
|
var text = ansiText[lastIndex..];
|
||||||
if (text.Length > 0)
|
if (text.Length > 0)
|
||||||
segments.Add(new ChatSegment(text, currentColor));
|
segments.Add(new ChatSegment(text, BuildAttr()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
|
// Matches: \x1b[0m (reset), \x1b[38;2;R;G;Bm (fg), or \x1b[48;2;R;G;Bm (bg)
|
||||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
private static partial Regex AnsiColorRegex();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +215,7 @@ public class ChatListSource : IListDataSource
|
|||||||
|
|
||||||
var chatLine = _lines[item];
|
var chatLine = _lines[item];
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
int charPos = 0;
|
int charPos = 0;
|
||||||
int drawnChars = 0;
|
int drawnChars = 0;
|
||||||
@@ -205,6 +223,9 @@ public class ChatListSource : IListDataSource
|
|||||||
foreach (var segment in chatLine.Segments)
|
foreach (var segment in chatLine.Segments)
|
||||||
{
|
{
|
||||||
var attr = segment.Color ?? normalAttr;
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
// Override background for mention-highlighted lines
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = new Attribute(attr.Foreground, mentionBg.Value);
|
||||||
listView.SetAttribute(attr);
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
foreach (var ch in segment.Text)
|
foreach (var ch in segment.Text)
|
||||||
@@ -218,8 +239,9 @@ public class ChatListSource : IListDataSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining width with spaces using default colors
|
// Fill remaining width with spaces
|
||||||
listView.SetAttribute(normalAttr);
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
while (drawnChars < width)
|
while (drawnChars < width)
|
||||||
{
|
{
|
||||||
listView.AddRune(new Rune(' '));
|
listView.AddRune(new Rune(' '));
|
||||||
@@ -242,6 +264,110 @@ public class ChatListSource : IListDataSource
|
|||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for colored channel list rendering.
|
||||||
|
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _channelNames.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
|
||||||
|
|
||||||
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(channels);
|
||||||
|
_unreadCounts.Clear();
|
||||||
|
foreach (var kv in unread)
|
||||||
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_activeChannel = activeChannel;
|
||||||
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var name = _channelNames[item];
|
||||||
|
var isActive = name == _activeChannel;
|
||||||
|
_unreadCounts.TryGetValue(name, out var unread);
|
||||||
|
var hasUnread = unread > 0;
|
||||||
|
|
||||||
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
|
var prefix = isActive ? "> " : " ";
|
||||||
|
var channelText = $"#{name}";
|
||||||
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
// Use focus attr if this row is selected
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(focusAttr);
|
||||||
|
foreach (var ch in (prefix + channelText + badge))
|
||||||
|
{
|
||||||
|
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Prefix
|
||||||
|
var prefixAttr = isActive ? ActiveAttr : NormalAttr;
|
||||||
|
listView.SetAttribute(prefixAttr);
|
||||||
|
foreach (var ch in prefix)
|
||||||
|
{
|
||||||
|
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel name
|
||||||
|
var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr;
|
||||||
|
listView.SetAttribute(nameAttr);
|
||||||
|
foreach (var ch in channelText)
|
||||||
|
{
|
||||||
|
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread badge
|
||||||
|
if (hasUnread)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(BadgeAttr);
|
||||||
|
foreach (var ch in badge)
|
||||||
|
{
|
||||||
|
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rest
|
||||||
|
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
while (drawnChars < width)
|
||||||
|
{
|
||||||
|
listView.AddRune(new Rune(' '));
|
||||||
|
drawnChars++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -249,6 +375,7 @@ public static class ChatColors
|
|||||||
{
|
{
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
||||||
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
@@ -21,10 +22,21 @@ public sealed class MainWindow : Runnable
|
|||||||
private readonly ListView _messageList;
|
private readonly ListView _messageList;
|
||||||
private readonly TextView _inputField;
|
private readonly TextView _inputField;
|
||||||
private readonly FrameView _chatFrame;
|
private readonly FrameView _chatFrame;
|
||||||
|
private readonly FrameView _inputFrame;
|
||||||
private readonly Label _statusLabel;
|
private readonly Label _statusLabel;
|
||||||
private readonly Label _topicLabel;
|
private readonly Label _topicLabel;
|
||||||
private MenuBar _menuBar;
|
private MenuBar _menuBar;
|
||||||
|
|
||||||
|
// Online users panel
|
||||||
|
private readonly FrameView _usersFrame;
|
||||||
|
private readonly ListView _usersList;
|
||||||
|
private bool _usersPanelVisible = true;
|
||||||
|
private const int UsersPanelWidth = 22;
|
||||||
|
private static readonly Key F2Key = Key.F2;
|
||||||
|
|
||||||
|
private static readonly string AppVersion =
|
||||||
|
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||||
|
|
||||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
||||||
private static readonly Key EnterKey = Key.Enter;
|
private static readonly Key EnterKey = Key.Enter;
|
||||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||||
@@ -36,13 +48,15 @@ public sealed class MainWindow : Runnable
|
|||||||
[
|
[
|
||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||||
"/topic", "/users", "/quit", "/help"
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
|
"/mute", "/unmute", "/role", "/nuke", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
|
private readonly ChannelListSource _channelListSource;
|
||||||
private string _currentChannel = string.Empty;
|
private string _currentChannel = string.Empty;
|
||||||
private string _currentUser = string.Empty;
|
private string _currentUser = string.Empty;
|
||||||
private int _lastChatWidth;
|
private int _lastChatWidth;
|
||||||
@@ -92,6 +106,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnCreateChannelRequested;
|
public event Action? OnCreateChannelRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to delete the current channel.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnDeleteChannelRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
public MainWindow(IApplication app)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
@@ -107,7 +126,7 @@ public sealed class MainWindow : Runnable
|
|||||||
Title = "Channels",
|
Title = "Channels",
|
||||||
X = 0,
|
X = 0,
|
||||||
Y = 1, // below menu bar
|
Y = 1, // below menu bar
|
||||||
Width = 25,
|
Width = 22,
|
||||||
Height = Dim.Fill(1) // leave room for status bar
|
Height = Dim.Fill(1) // leave room for status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,7 +137,8 @@ public sealed class MainWindow : Runnable
|
|||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(),
|
||||||
Height = Dim.Fill()
|
Height = Dim.Fill()
|
||||||
};
|
};
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource = new ChannelListSource();
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
||||||
channelsFrame.Add(_channelList);
|
channelsFrame.Add(_channelList);
|
||||||
Add(channelsFrame);
|
Add(channelsFrame);
|
||||||
@@ -127,9 +147,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_topicLabel = new Label
|
_topicLabel = new Label
|
||||||
{
|
{
|
||||||
Text = "",
|
Text = "",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1,
|
Y = 1,
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 1,
|
Height = 1,
|
||||||
Visible = false
|
Visible = false
|
||||||
};
|
};
|
||||||
@@ -139,9 +159,9 @@ public sealed class MainWindow : Runnable
|
|||||||
_chatFrame = new FrameView
|
_chatFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Chat",
|
Title = "Chat",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
Y = 1, // below menu bar (shifts to 2 when topic is visible)
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = Dim.Fill(6) // leave room for input area and status bar
|
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -157,12 +177,12 @@ public sealed class MainWindow : Runnable
|
|||||||
Add(_chatFrame);
|
Add(_chatFrame);
|
||||||
|
|
||||||
// Bottom input area
|
// Bottom input area
|
||||||
var inputFrame = new FrameView
|
_inputFrame = new FrameView
|
||||||
{
|
{
|
||||||
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
|
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||||
X = 25,
|
X = 22,
|
||||||
Y = Pos.Bottom(_chatFrame),
|
Y = Pos.Bottom(_chatFrame),
|
||||||
Width = Dim.Fill(),
|
Width = Dim.Fill(UsersPanelWidth),
|
||||||
Height = 5
|
Height = 5
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,8 +195,29 @@ public sealed class MainWindow : Runnable
|
|||||||
WordWrap = true
|
WordWrap = true
|
||||||
};
|
};
|
||||||
_inputField.KeyDown += OnInputKeyDown;
|
_inputField.KeyDown += OnInputKeyDown;
|
||||||
inputFrame.Add(_inputField);
|
_inputFrame.Add(_inputField);
|
||||||
Add(inputFrame);
|
Add(_inputFrame);
|
||||||
|
|
||||||
|
// Right panel - online users
|
||||||
|
_usersFrame = new FrameView
|
||||||
|
{
|
||||||
|
Title = "Users",
|
||||||
|
X = Pos.AnchorEnd(UsersPanelWidth),
|
||||||
|
Y = 1,
|
||||||
|
Width = UsersPanelWidth,
|
||||||
|
Height = Dim.Fill(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
_usersList = new ListView
|
||||||
|
{
|
||||||
|
X = 0,
|
||||||
|
Y = 0,
|
||||||
|
Width = Dim.Fill(),
|
||||||
|
Height = Dim.Fill()
|
||||||
|
};
|
||||||
|
_usersList.SetSource(new ObservableCollection<string>());
|
||||||
|
_usersFrame.Add(_usersList);
|
||||||
|
Add(_usersFrame);
|
||||||
|
|
||||||
// Status bar at the very bottom
|
// Status bar at the very bottom
|
||||||
_statusLabel = new Label
|
_statusLabel = new Label
|
||||||
@@ -198,7 +239,7 @@ public sealed class MainWindow : Runnable
|
|||||||
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
|
||||||
|
|
||||||
// Window-level key handling for Ctrl+C (quit)
|
// Window-level key handling for Ctrl+C (quit), F2 (toggle users panel)
|
||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +306,11 @@ public sealed class MainWindow : Runnable
|
|||||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||||
new Line(),
|
new Line(),
|
||||||
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
|
||||||
|
new Line(),
|
||||||
|
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
|
||||||
}),
|
}),
|
||||||
new MenuBarItem("_User", allUserItems)
|
new MenuBarItem("_User", allUserItems)
|
||||||
]);
|
]);
|
||||||
@@ -386,12 +431,16 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnWindowKeyDown(object? sender, Key e)
|
private void OnWindowKeyDown(object? sender, Key e)
|
||||||
{
|
{
|
||||||
// Ctrl+C quits from anywhere
|
|
||||||
if (e.KeyCode == CtrlCKey.KeyCode)
|
if (e.KeyCode == CtrlCKey.KeyCode)
|
||||||
{
|
{
|
||||||
_app.RequestStop();
|
_app.RequestStop();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
else if (e.KeyCode == F2Key.KeyCode)
|
||||||
|
{
|
||||||
|
ToggleUsersPanel();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -475,6 +524,32 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all lines associated with a specific message ID.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveMessage(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.RemoveAll(l => l.MessageId == messageId);
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages from a specific channel.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChannelMessages(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.Clear();
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
RefreshMessages();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -515,9 +590,9 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateStatusBar(string status)
|
public void UpdateStatusBar(string status)
|
||||||
{
|
{
|
||||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
|
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
|
||||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
|
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
|
||||||
_statusLabel.Text = $" {status}{userPart}{channelPart}";
|
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -585,10 +660,13 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_currentChannel = string.Empty;
|
_currentChannel = string.Empty;
|
||||||
_currentUser = string.Empty;
|
_currentUser = string.Empty;
|
||||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
_channelListSource.Update([], [], string.Empty);
|
||||||
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
_topicLabel.Visible = false;
|
_topicLabel.Visible = false;
|
||||||
_chatFrame.Y = 1;
|
_chatFrame.Y = 1;
|
||||||
|
_usersList.SetSource(new ObservableCollection<string>());
|
||||||
|
_usersFrame.Title = "Users";
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,13 +718,8 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
var displayNames = _channelNames.Select(name =>
|
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
|
||||||
{
|
_channelList.Source = _channelListSource;
|
||||||
_channelUnread.TryGetValue(name, out var unread);
|
|
||||||
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
_channelList.SetSource(new ObservableCollection<string>(displayNames));
|
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
var idx = _channelNames.IndexOf(_currentChannel);
|
var idx = _channelNames.IndexOf(_currentChannel);
|
||||||
@@ -673,11 +746,63 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateLayout()
|
||||||
|
{
|
||||||
|
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
|
||||||
|
_chatFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_topicLabel.Width = Dim.Fill(rightMargin);
|
||||||
|
_inputFrame.Width = Dim.Fill(rightMargin);
|
||||||
|
_usersFrame.Visible = _usersPanelVisible;
|
||||||
|
SetNeedsDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Toggle the online users panel visibility (F2).
|
||||||
|
/// </summary>
|
||||||
|
public void ToggleUsersPanel()
|
||||||
|
{
|
||||||
|
_usersPanelVisible = !_usersPanelVisible;
|
||||||
|
UpdateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the online users list display.
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateOnlineUsers(List<UserPresenceDto> users)
|
||||||
|
{
|
||||||
|
var displayItems = users.Select(u =>
|
||||||
|
{
|
||||||
|
var statusIcon = u.Status switch
|
||||||
|
{
|
||||||
|
UserStatus.Online => "\u25cf", // ●
|
||||||
|
UserStatus.Away => "\u25cb", // ○
|
||||||
|
UserStatus.DoNotDisturb => "\u25d0", // ◐
|
||||||
|
UserStatus.Invisible => "\u25cc", // ◌
|
||||||
|
_ => " "
|
||||||
|
};
|
||||||
|
var name = u.DisplayName ?? u.Username;
|
||||||
|
var roleTag = u.Role switch
|
||||||
|
{
|
||||||
|
ServerRole.Owner => "\u2605", // ★
|
||||||
|
ServerRole.Admin => "\u2666", // ♦
|
||||||
|
ServerRole.Mod => "\u2740", // ❀
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
return $"{statusIcon} {roleTag}{name}";
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
_usersList.SetSource(new ObservableCollection<string>(displayItems));
|
||||||
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||||
/// Timestamps are dimmed and sender names are colored.
|
/// Timestamps are dimmed and sender names are colored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<ChatLine> FormatMessage(MessageDto message)
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
{
|
{
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
var senderName = message.SenderUsername + ":";
|
var senderName = message.SenderUsername + ":";
|
||||||
@@ -724,6 +849,21 @@ public sealed class MainWindow : Runnable
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag all lines with the message ID for deletion support
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.MessageId = message.Id;
|
||||||
|
|
||||||
|
// Check for @mention of current user
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.IsMention = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||||
@@ -23,7 +23,7 @@ public sealed class ProfileEditDialog
|
|||||||
{
|
{
|
||||||
ProfileEditResult? result = null;
|
ProfileEditResult? result = null;
|
||||||
|
|
||||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 22 };
|
||||||
|
|
||||||
// Display Name
|
// Display Name
|
||||||
var nameLabel = new Label
|
var nameLabel = new Label
|
||||||
@@ -102,20 +102,66 @@ public sealed class ProfileEditDialog
|
|||||||
UpdateColorPreview(colorPreview, colorField.Text);
|
UpdateColorPreview(colorPreview, colorField.Text);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
var avatarLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "Avatar:",
|
||||||
|
X = 1,
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarField = new TextField
|
||||||
|
{
|
||||||
|
Text = "",
|
||||||
|
X = 17,
|
||||||
|
Y = 10,
|
||||||
|
Width = Dim.Fill(12)
|
||||||
|
};
|
||||||
|
var browseButton = new Button
|
||||||
|
{
|
||||||
|
Text = "Browse",
|
||||||
|
X = Pos.AnchorEnd(10),
|
||||||
|
Y = 10
|
||||||
|
};
|
||||||
|
var avatarHintLabel = new Label
|
||||||
|
{
|
||||||
|
Text = "(file path or URL)",
|
||||||
|
X = 17,
|
||||||
|
Y = 11
|
||||||
|
};
|
||||||
|
avatarHintLabel.SetScheme(new Scheme
|
||||||
|
{
|
||||||
|
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||||
|
});
|
||||||
|
|
||||||
|
browseButton.Accepting += (s, e) =>
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
var openDialog = new OpenDialog
|
||||||
|
{
|
||||||
|
Title = "Select Avatar Image",
|
||||||
|
OpenMode = OpenMode.File,
|
||||||
|
};
|
||||||
|
app.Run(openDialog);
|
||||||
|
if (openDialog.FilePaths.Count > 0)
|
||||||
|
{
|
||||||
|
avatarField.Text = openDialog.FilePaths[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var saveButton = new Button
|
var saveButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Save",
|
Text = "Save",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 10,
|
X = Pos.Center() - 10,
|
||||||
Y = 10
|
Y = 14
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 5,
|
X = Pos.Center() + 5,
|
||||||
Y = 10
|
Y = 14
|
||||||
};
|
};
|
||||||
|
|
||||||
saveButton.Accepting += (s, e) =>
|
saveButton.Accepting += (s, e) =>
|
||||||
@@ -123,8 +169,9 @@ public sealed class ProfileEditDialog
|
|||||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||||
|
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
|
||||||
|
|
||||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -137,7 +184,9 @@ public sealed class ProfileEditDialog
|
|||||||
};
|
};
|
||||||
|
|
||||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
colorHintLabel, previewLabel, colorPreview,
|
||||||
|
avatarLabel, avatarField, browseButton, avatarHintLabel,
|
||||||
|
saveButton, cancelButton);
|
||||||
|
|
||||||
nameField.SetFocus();
|
nameField.SetFocus();
|
||||||
app.Run(dialog);
|
app.Run(dialog);
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ public static class HubConstants
|
|||||||
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
|
||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
|
public const int AsciiArtHeightHalfBlock = 80;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,9 @@ public interface IChatBroadcaster
|
|||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
Task SendUserStatusChangedAsync(List<string> channelNames, UserPresenceDto presence);
|
||||||
|
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
||||||
|
Task SendUserBannedAsync(string username, string? reason);
|
||||||
|
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
||||||
|
Task SendChannelNukedAsync(string channelName);
|
||||||
Task SendErrorAsync(string connectionId, string message);
|
Task SendErrorAsync(string connectionId, string message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,9 @@ public interface IEchoHubClient
|
|||||||
Task UserLeft(string channelName, string username);
|
Task UserLeft(string channelName, string username);
|
||||||
Task ChannelUpdated(ChannelDto channel);
|
Task ChannelUpdated(ChannelDto channel);
|
||||||
Task UserStatusChanged(UserPresenceDto presence);
|
Task UserStatusChanged(UserPresenceDto presence);
|
||||||
|
Task UserKicked(string channelName, string username, string? reason);
|
||||||
|
Task UserBanned(string username, string? reason);
|
||||||
|
Task MessageDeleted(string channelName, Guid messageId);
|
||||||
|
Task ChannelNuked(string channelName);
|
||||||
Task Error(string message);
|
Task Error(string message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
|
public record AssignRoleRequest(string Username, ServerRole Role);
|
||||||
|
public record MuteRequest(string? Reason = null, int? DurationMinutes = null);
|
||||||
|
public record BanRequest(string? Reason = null);
|
||||||
|
public record KickRequest(string? Reason = null);
|
||||||
@@ -11,6 +11,7 @@ public record UserProfileDto(
|
|||||||
string? AvatarAscii,
|
string? AvatarAscii,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage,
|
string? StatusMessage,
|
||||||
|
ServerRole Role,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset LastSeenAt);
|
DateTimeOffset LastSeenAt);
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ public record UserPresenceDto(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? NicknameColor,
|
string? NicknameColor,
|
||||||
UserStatus Status,
|
UserStatus Status,
|
||||||
string? StatusMessage);
|
string? StatusMessage,
|
||||||
|
ServerRole Role);
|
||||||
|
|
||||||
public record AvatarUploadResponse(string AvatarAscii);
|
public record AvatarUploadResponse(string AvatarAscii);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
public enum ServerRole
|
||||||
|
{
|
||||||
|
Member = 0,
|
||||||
|
Mod = 1,
|
||||||
|
Admin = 2,
|
||||||
|
Owner = 3
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ public class User
|
|||||||
public string? AvatarAscii { get; set; }
|
public string? AvatarAscii { get; set; }
|
||||||
public UserStatus Status { get; set; } = UserStatus.Online;
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
public string? StatusMessage { get; set; }
|
public string? StatusMessage { get; set; }
|
||||||
|
public ServerRole Role { get; set; } = ServerRole.Member;
|
||||||
|
public bool IsMuted { get; set; }
|
||||||
|
public DateTimeOffset? MutedUntil { get; set; }
|
||||||
|
public bool IsBanned { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,41 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason is not null ? $" :{reason}" : "";
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} KICK #{channelName} {username}{reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendUserBannedAsync(string username, string? reason)
|
||||||
|
{
|
||||||
|
var reasonText = reason ?? "You have been banned.";
|
||||||
|
foreach (var conn in _gateway.GetAllConnections())
|
||||||
|
{
|
||||||
|
if (conn.Nickname == username)
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {username} :You have been banned: {reasonText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Message {messageId} was deleted in #{channelName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendChannelNukedAsync(string channelName)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :All messages in #{channelName} have been cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendErrorAsync(string connectionId, string message)
|
public async Task SendErrorAsync(string connectionId, string message)
|
||||||
{
|
{
|
||||||
if (!connectionId.StartsWith("irc-")) return;
|
if (!connectionId.StartsWith("irc-")) return;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IEnumerable<IrcClientConnection> GetAllConnections()
|
||||||
|
{
|
||||||
|
return _connections.Values.Where(c => c.IsAuthenticated);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class JwtTokenService
|
|||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
new("username", user.Username),
|
new("username", user.Username),
|
||||||
new("display_name", user.DisplayName ?? user.Username),
|
new("display_name", user.DisplayName ?? user.Username),
|
||||||
|
new("role", user.Role.ToString()),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,16 @@ public class AuthController : ControllerBase
|
|||||||
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
if (await _db.Users.AnyAsync(u => u.Username == normalizedUsername))
|
||||||
return Conflict(new ErrorResponse("Username is already taken."));
|
return Conflict(new ErrorResponse("Username is already taken."));
|
||||||
|
|
||||||
|
// First registered user on the server becomes the Owner
|
||||||
|
var isFirstUser = !await _db.Users.AnyAsync();
|
||||||
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Username = normalizedUsername,
|
Username = normalizedUsername,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||||
DisplayName = request.DisplayName?.Trim(),
|
DisplayName = request.DisplayName?.Trim(),
|
||||||
|
Role = isFirstUser ? ServerRole.Owner : ServerRole.Member,
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.Users.Add(user);
|
_db.Users.Add(user);
|
||||||
@@ -80,6 +84,9 @@ public class AuthController : ControllerBase
|
|||||||
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
|
||||||
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
return Unauthorized(new ErrorResponse("Invalid username or password."));
|
||||||
|
|
||||||
|
if (user.IsBanned)
|
||||||
|
return Unauthorized(new ErrorResponse("Your account has been banned."));
|
||||||
|
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
user.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,10 @@ public class ChannelsController : ControllerBase
|
|||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
|
var userId = Guid.Parse(userIdClaim);
|
||||||
return StatusCode(403, new ErrorResponse("Only the channel creator can delete the channel."));
|
var caller = await _db.Users.FindAsync(userId);
|
||||||
|
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
|
||||||
|
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
|
||||||
|
|
||||||
_db.Channels.Remove(dbChannel);
|
_db.Channels.Remove(dbChannel);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/moderation")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("general")]
|
||||||
|
public class ModerationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly EchoHubDbContext _db;
|
||||||
|
private readonly IChatService _chatService;
|
||||||
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
|
||||||
|
public ModerationController(
|
||||||
|
EchoHubDbContext db,
|
||||||
|
IChatService chatService,
|
||||||
|
PresenceTracker presenceTracker,
|
||||||
|
IEnumerable<IChatBroadcaster> broadcasters)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_chatService = chatService;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("role")]
|
||||||
|
public async Task<IActionResult> AssignRole([FromBody] AssignRoleRequest request)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
if (request.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign the Owner role."));
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == request.Username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{request.Username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role == ServerRole.Owner)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot change the server owner's role."));
|
||||||
|
|
||||||
|
if (request.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||||
|
|
||||||
|
target.Role = request.Role;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("kick/{username}")]
|
||||||
|
public async Task<IActionResult> KickUser(string username, [FromBody] KickRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot kick a user with equal or higher role."));
|
||||||
|
|
||||||
|
// Broadcast kick to all channels the user is in
|
||||||
|
var channels = _presenceTracker.GetChannelsForUser(target.Username);
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserKickedAsync(channel, target.Username, request?.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been kicked." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("ban/{username}")]
|
||||||
|
public async Task<IActionResult> BanUser(string username, [FromBody] BanRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot ban a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsBanned = true;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendUserBannedAsync(target.Username, request?.Reason));
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unban/{username}")]
|
||||||
|
public async Task<IActionResult> UnbanUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsBanned = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("mute/{username}")]
|
||||||
|
public async Task<IActionResult> MuteUser(string username, [FromBody] MuteRequest? request = null)
|
||||||
|
{
|
||||||
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
if (target.Role >= caller!.Role)
|
||||||
|
return BadRequest(new ErrorResponse("Cannot mute a user with equal or higher role."));
|
||||||
|
|
||||||
|
target.IsMuted = true;
|
||||||
|
target.MutedUntil = request?.DurationMinutes is > 0
|
||||||
|
? DateTimeOffset.UtcNow.AddMinutes(request.DurationMinutes.Value)
|
||||||
|
: null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||||
|
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unmute/{username}")]
|
||||||
|
public async Task<IActionResult> UnmuteUser(string username)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
|
if (target is null)
|
||||||
|
return NotFound(new ErrorResponse($"User '{username}' not found."));
|
||||||
|
|
||||||
|
target.IsMuted = false;
|
||||||
|
target.MutedUntil = null;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("messages/{messageId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var message = await _db.Messages
|
||||||
|
.Include(m => m.Channel)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == messageId);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
return NotFound(new ErrorResponse("Message not found."));
|
||||||
|
|
||||||
|
var channelName = message.Channel!.Name;
|
||||||
|
_db.Messages.Remove(message);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
||||||
|
|
||||||
|
return Ok(new { Message = "Message deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("channels/{channel}/nuke")]
|
||||||
|
public async Task<IActionResult> NukeChannel(string channel)
|
||||||
|
{
|
||||||
|
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
|
if (error is not null) return error;
|
||||||
|
|
||||||
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
|
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (dbChannel is null)
|
||||||
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
|
||||||
|
_db.Messages.RemoveRange(messages);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
||||||
|
|
||||||
|
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||||
|
{
|
||||||
|
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (userIdClaim is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||||
|
|
||||||
|
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||||
|
if (caller is null)
|
||||||
|
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||||
|
|
||||||
|
if (caller.Role < minimumRole)
|
||||||
|
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||||
|
|
||||||
|
return (caller, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
|
{
|
||||||
|
foreach (var broadcaster in _broadcasters)
|
||||||
|
{
|
||||||
|
try { await action(broadcaster); }
|
||||||
|
catch { /* logged by broadcaster */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ public class UsersController : ControllerBase
|
|||||||
user.AvatarAscii,
|
user.AvatarAscii,
|
||||||
user.Status,
|
user.Status,
|
||||||
user.StatusMessage,
|
user.StatusMessage,
|
||||||
|
user.Role,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.LastSeenAt);
|
user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public class EchoHubDbContext : DbContext
|
|||||||
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
entity.Property(u => u.NicknameColor).HasMaxLength(7);
|
||||||
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
entity.Property(u => u.AvatarAscii).HasMaxLength(10000);
|
||||||
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
entity.Property(u => u.StatusMessage).HasMaxLength(100);
|
||||||
|
entity.Property(u => u.Role).HasConversion<int>();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Channel>(entity =>
|
modelBuilder.Entity<Channel>(entity =>
|
||||||
|
|||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260219162414_AddModerationRoles")]
|
||||||
|
partial class AddModerationRoles
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddModerationRoles : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsBanned",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsMuted",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MutedUntil",
|
||||||
|
table: "Users");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Role",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,9 +144,18 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<long>("LastSeenAt")
|
b.Property<long>("LastSeenAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("NicknameColor")
|
b.Property<string>("NicknameColor")
|
||||||
.HasMaxLength(7)
|
.HasMaxLength(7)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -155,6 +164,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ public class ChatService : IChatService
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
UserStatus.Invisible,
|
UserStatus.Invisible,
|
||||||
user.StatusMessage);
|
user.StatusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
|
||||||
}
|
}
|
||||||
@@ -139,6 +140,21 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
|
// Check mute status
|
||||||
|
if (sender is not null && sender.IsMuted)
|
||||||
|
{
|
||||||
|
if (sender.MutedUntil.HasValue && sender.MutedUntil.Value <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
sender.IsMuted = false;
|
||||||
|
sender.MutedUntil = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return "You are muted and cannot send messages.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -203,7 +219,8 @@ public class ChatService : IChatService
|
|||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.NicknameColor,
|
user.NicknameColor,
|
||||||
status,
|
status,
|
||||||
statusMessage);
|
statusMessage,
|
||||||
|
user.Role);
|
||||||
|
|
||||||
var channels = _presenceTracker.GetChannelsForUser(username);
|
var channels = _presenceTracker.GetChannelsForUser(username);
|
||||||
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
|
||||||
@@ -226,7 +243,8 @@ public class ChatService : IChatService
|
|||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
u.NicknameColor,
|
u.NicknameColor,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.StatusMessage))
|
u.StatusMessage,
|
||||||
|
u.Role))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +282,7 @@ public class ChatService : IChatService
|
|||||||
return new UserProfileDto(
|
return new UserProfileDto(
|
||||||
user.Id, user.Username, user.DisplayName, user.Bio,
|
user.Id, user.Username, user.DisplayName, user.Bio,
|
||||||
user.NicknameColor, user.AvatarAscii, user.Status,
|
user.NicknameColor, user.AvatarAscii, user.Status,
|
||||||
user.StatusMessage, user.CreatedAt, user.LastSeenAt);
|
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
|
||||||
|
|||||||
@@ -8,47 +8,72 @@ namespace EchoHub.Server.Services;
|
|||||||
|
|
||||||
public class ImageToAsciiService
|
public class ImageToAsciiService
|
||||||
{
|
{
|
||||||
private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray();
|
/// <summary>
|
||||||
|
/// Converts an image to ASCII art using half-block characters (▀▄█) with
|
||||||
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight)
|
/// 24-bit ANSI foreground and background colors for 2x vertical resolution.
|
||||||
|
/// Each character cell represents two vertical pixels.
|
||||||
|
/// </summary>
|
||||||
|
public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeightHalfBlock)
|
||||||
{
|
{
|
||||||
using var image = Image.Load<Rgba32>(imageStream);
|
using var image = Image.Load<Rgba32>(imageStream);
|
||||||
|
|
||||||
|
// Ensure height is even for pair processing
|
||||||
|
if (height % 2 != 0) height++;
|
||||||
|
|
||||||
image.Mutate(x => x.Resize(width, height));
|
image.Mutate(x => x.Resize(width, height));
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
byte lastR = 0, lastG = 0, lastB = 0;
|
for (int y = 0; y < image.Height; y += 2)
|
||||||
bool hasLastColor = false;
|
|
||||||
|
|
||||||
for (int y = 0; y < image.Height; y++)
|
|
||||||
{
|
{
|
||||||
|
byte lastFgR = 0, lastFgG = 0, lastFgB = 0;
|
||||||
|
byte lastBgR = 0, lastBgG = 0, lastBgB = 0;
|
||||||
|
bool hasLastColor = false;
|
||||||
|
|
||||||
for (int x = 0; x < image.Width; x++)
|
for (int x = 0; x < image.Width; x++)
|
||||||
{
|
{
|
||||||
var pixel = image[x, y];
|
var topPixel = image[x, y];
|
||||||
var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B;
|
var bottomPixel = (y + 1 < image.Height) ? image[x, y + 1] : topPixel;
|
||||||
|
|
||||||
// Map brightness (0-255) to ASCII char index
|
byte fgR, fgG, fgB, bgR, bgG, bgB;
|
||||||
var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1));
|
char blockChar;
|
||||||
|
|
||||||
// Emit ANSI 24-bit color only when it changes
|
if (topPixel.R == bottomPixel.R && topPixel.G == bottomPixel.G && topPixel.B == bottomPixel.B)
|
||||||
if (!hasLastColor || pixel.R != lastR || pixel.G != lastG || pixel.B != lastB)
|
|
||||||
{
|
{
|
||||||
sb.Append($"\x1b[38;2;{pixel.R};{pixel.G};{pixel.B}m");
|
// Both pixels same color — full block
|
||||||
lastR = pixel.R;
|
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||||
lastG = pixel.G;
|
bgR = topPixel.R; bgG = topPixel.G; bgB = topPixel.B;
|
||||||
lastB = pixel.B;
|
blockChar = '\u2588'; // █
|
||||||
hasLastColor = true;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Top pixel = foreground, bottom pixel = background, upper half block
|
||||||
|
fgR = topPixel.R; fgG = topPixel.G; fgB = topPixel.B;
|
||||||
|
bgR = bottomPixel.R; bgG = bottomPixel.G; bgB = bottomPixel.B;
|
||||||
|
blockChar = '\u2580'; // ▀
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.Append(AsciiChars[index]);
|
// Emit color codes only when they change
|
||||||
|
bool fgChanged = !hasLastColor || fgR != lastFgR || fgG != lastFgG || fgB != lastFgB;
|
||||||
|
bool bgChanged = !hasLastColor || bgR != lastBgR || bgG != lastBgG || bgB != lastBgB;
|
||||||
|
|
||||||
|
if (fgChanged)
|
||||||
|
sb.Append($"\x1b[38;2;{fgR};{fgG};{fgB}m");
|
||||||
|
if (bgChanged)
|
||||||
|
sb.Append($"\x1b[48;2;{bgR};{bgG};{bgB}m");
|
||||||
|
|
||||||
|
sb.Append(blockChar);
|
||||||
|
|
||||||
|
lastFgR = fgR; lastFgG = fgG; lastFgB = fgB;
|
||||||
|
lastBgR = bgR; lastBgG = bgG; lastBgB = bgB;
|
||||||
|
hasLastColor = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset color at end of line
|
// Reset color at end of line
|
||||||
sb.Append("\x1b[0m");
|
sb.Append("\x1b[0m");
|
||||||
hasLastColor = false;
|
hasLastColor = false;
|
||||||
|
|
||||||
if (y < image.Height - 1)
|
if (y + 2 < image.Height)
|
||||||
{
|
{
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
return HubContext.Clients.Clients(connections).UserStatusChanged(presence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendUserKickedAsync(string channelName, string username, string? reason)
|
||||||
|
=> HubContext.Clients.Group(channelName).UserKicked(channelName, username, reason);
|
||||||
|
|
||||||
|
public Task SendUserBannedAsync(string username, string? reason)
|
||||||
|
=> HubContext.Clients.All.UserBanned(username, reason);
|
||||||
|
|
||||||
|
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
|
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
||||||
|
|
||||||
|
public Task SendChannelNukedAsync(string channelName)
|
||||||
|
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
||||||
|
|
||||||
public Task SendErrorAsync(string connectionId, string message)
|
public Task SendErrorAsync(string connectionId, string message)
|
||||||
{
|
{
|
||||||
if (connectionId.StartsWith("irc-"))
|
if (connectionId.StartsWith("irc-"))
|
||||||
|
|||||||
Reference in New Issue
Block a user