From cfa8b90d046fd330930a645f4b149632e0adc968 Mon Sep 17 00:00:00 2001 From: HueByte Date: Wed, 18 Feb 2026 13:52:19 +0100 Subject: [PATCH] Add core models, DTOs, and services for EchoHub chat application - Created User, Channel, Message, and ServerInfo models with necessary properties. - Added DTOs for user profiles, server information, and message handling. - Implemented JWT authentication service for user login and token generation. - Developed Entity Framework Core DbContext for database interactions. - Introduced SignalR ChatHub for real-time messaging and presence tracking. - Implemented file storage and image to ASCII conversion services. - Set up CORS and middleware for API endpoints. - Created launch settings and development configuration for server and web projects. --- .gitignore | 9 + EchoHub.slnx | 9 + .../Data/ServerDirectoryDbContext.cs | 18 + src/EchoHub.Api/EchoHub.Api.csproj | 21 + .../Extensions/ServerDirectoryExtensions.cs | 69 ++ .../Services/ServerDirectoryService.cs | 90 +++ src/EchoHub.Client/Commands/CommandHandler.cs | 224 ++++++ src/EchoHub.Client/Config/ClientConfig.cs | 24 + src/EchoHub.Client/Config/ConfigManager.cs | 70 ++ src/EchoHub.Client/EchoHub.Client.csproj | 21 + src/EchoHub.Client/Program.cs | 717 ++++++++++++++++++ src/EchoHub.Client/Services/ApiClient.cs | 172 +++++ .../Services/EchoHubConnection.cs | 135 ++++ src/EchoHub.Client/Themes/Theme.cs | 18 + src/EchoHub.Client/Themes/ThemeManager.cs | 317 ++++++++ src/EchoHub.Client/UI/ConnectDialog.cs | 228 ++++++ src/EchoHub.Client/UI/MainWindow.cs | 511 +++++++++++++ src/EchoHub.Client/UI/ProfileEditDialog.cs | 190 +++++ src/EchoHub.Client/UI/StatusDialog.cs | 96 +++ src/EchoHub.Client/UI/UserPanelDialog.cs | 336 ++++++++ src/EchoHub.Core/Constants/HubConstants.cs | 13 + src/EchoHub.Core/Contracts/IEchoHubClient.cs | 16 + src/EchoHub.Core/DTOs/AuthDtos.cs | 7 + src/EchoHub.Core/DTOs/ChatDtos.cs | 31 + src/EchoHub.Core/DTOs/ProfileDtos.cs | 31 + src/EchoHub.Core/DTOs/ServerDtos.cs | 15 + src/EchoHub.Core/EchoHub.Core.csproj | 9 + src/EchoHub.Core/Models/Channel.cs | 12 + src/EchoHub.Core/Models/Message.cs | 17 + src/EchoHub.Core/Models/MessageType.cs | 8 + src/EchoHub.Core/Models/ServerInfo.cs | 14 + src/EchoHub.Core/Models/User.cs | 16 + src/EchoHub.Core/Models/UserStatus.cs | 9 + src/EchoHub.Server/Auth/JwtTokenService.cs | 40 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 76 ++ src/EchoHub.Server/EchoHub.Server.csproj | 20 + src/EchoHub.Server/Hubs/ChatHub.cs | 263 +++++++ src/EchoHub.Server/Program.cs | 399 ++++++++++ .../Properties/launchSettings.json | 23 + .../Services/FileStorageService.cs | 46 ++ .../Services/ImageToAsciiService.cs | 41 + .../Services/PresenceTracker.cs | 113 +++ .../appsettings.Development.json | 8 + src/EchoHub.Web/EchoHub.Web.csproj | 18 + src/EchoHub.Web/Program.cs | 45 ++ .../Properties/launchSettings.json | 23 + src/EchoHub.Web/appsettings.Development.json | 8 + 47 files changed, 4596 insertions(+) create mode 100644 EchoHub.slnx create mode 100644 src/EchoHub.Api/Data/ServerDirectoryDbContext.cs create mode 100644 src/EchoHub.Api/EchoHub.Api.csproj create mode 100644 src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs create mode 100644 src/EchoHub.Api/Services/ServerDirectoryService.cs create mode 100644 src/EchoHub.Client/Commands/CommandHandler.cs create mode 100644 src/EchoHub.Client/Config/ClientConfig.cs create mode 100644 src/EchoHub.Client/Config/ConfigManager.cs create mode 100644 src/EchoHub.Client/EchoHub.Client.csproj create mode 100644 src/EchoHub.Client/Program.cs create mode 100644 src/EchoHub.Client/Services/ApiClient.cs create mode 100644 src/EchoHub.Client/Services/EchoHubConnection.cs create mode 100644 src/EchoHub.Client/Themes/Theme.cs create mode 100644 src/EchoHub.Client/Themes/ThemeManager.cs create mode 100644 src/EchoHub.Client/UI/ConnectDialog.cs create mode 100644 src/EchoHub.Client/UI/MainWindow.cs create mode 100644 src/EchoHub.Client/UI/ProfileEditDialog.cs create mode 100644 src/EchoHub.Client/UI/StatusDialog.cs create mode 100644 src/EchoHub.Client/UI/UserPanelDialog.cs create mode 100644 src/EchoHub.Core/Constants/HubConstants.cs create mode 100644 src/EchoHub.Core/Contracts/IEchoHubClient.cs create mode 100644 src/EchoHub.Core/DTOs/AuthDtos.cs create mode 100644 src/EchoHub.Core/DTOs/ChatDtos.cs create mode 100644 src/EchoHub.Core/DTOs/ProfileDtos.cs create mode 100644 src/EchoHub.Core/DTOs/ServerDtos.cs create mode 100644 src/EchoHub.Core/EchoHub.Core.csproj create mode 100644 src/EchoHub.Core/Models/Channel.cs create mode 100644 src/EchoHub.Core/Models/Message.cs create mode 100644 src/EchoHub.Core/Models/MessageType.cs create mode 100644 src/EchoHub.Core/Models/ServerInfo.cs create mode 100644 src/EchoHub.Core/Models/User.cs create mode 100644 src/EchoHub.Core/Models/UserStatus.cs create mode 100644 src/EchoHub.Server/Auth/JwtTokenService.cs create mode 100644 src/EchoHub.Server/Data/EchoHubDbContext.cs create mode 100644 src/EchoHub.Server/EchoHub.Server.csproj create mode 100644 src/EchoHub.Server/Hubs/ChatHub.cs create mode 100644 src/EchoHub.Server/Program.cs create mode 100644 src/EchoHub.Server/Properties/launchSettings.json create mode 100644 src/EchoHub.Server/Services/FileStorageService.cs create mode 100644 src/EchoHub.Server/Services/ImageToAsciiService.cs create mode 100644 src/EchoHub.Server/Services/PresenceTracker.cs create mode 100644 src/EchoHub.Server/appsettings.Development.json create mode 100644 src/EchoHub.Web/EchoHub.Web.csproj create mode 100644 src/EchoHub.Web/Program.cs create mode 100644 src/EchoHub.Web/Properties/launchSettings.json create mode 100644 src/EchoHub.Web/appsettings.Development.json diff --git a/.gitignore b/.gitignore index ce89292..4fc3dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -410,9 +410,18 @@ FodyWeavers.xsd # Built Visual Studio Code Extensions *.vsix +# Dev folder +.dev/ + # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp + +.dev/* +appsettings.json +CLAUDE.md +*.db +*.db-* \ No newline at end of file diff --git a/EchoHub.slnx b/EchoHub.slnx new file mode 100644 index 0000000..b0939c0 --- /dev/null +++ b/EchoHub.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs b/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs new file mode 100644 index 0000000..c31b409 --- /dev/null +++ b/src/EchoHub.Api/Data/ServerDirectoryDbContext.cs @@ -0,0 +1,18 @@ +using EchoHub.Core.Models; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Api.Data; + +public class ServerDirectoryDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Servers => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Url).IsUnique(); + }); + } +} diff --git a/src/EchoHub.Api/EchoHub.Api.csproj b/src/EchoHub.Api/EchoHub.Api.csproj new file mode 100644 index 0000000..646dd70 --- /dev/null +++ b/src/EchoHub.Api/EchoHub.Api.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs b/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs new file mode 100644 index 0000000..320bcbe --- /dev/null +++ b/src/EchoHub.Api/Extensions/ServerDirectoryExtensions.cs @@ -0,0 +1,69 @@ +using EchoHub.Api.Data; +using EchoHub.Api.Services; +using EchoHub.Core.DTOs; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace EchoHub.Api.Extensions; + +public static class ServerDirectoryExtensions +{ + public static IServiceCollection AddServerDirectory(this IServiceCollection services, string connectionString) + { + services.AddDbContext(options => + options.UseSqlite(connectionString)); + + services.AddScoped(); + + return services; + } + + public static WebApplication MapServerDirectoryApi(this WebApplication app) + { + var group = app.MapGroup("/api/servers"); + + group.MapGet("/", async (ServerDirectoryService service) => + { + var servers = await service.GetAllServersAsync(); + return Results.Ok(servers); + }); + + group.MapGet("/search", async (string q, ServerDirectoryService service) => + { + if (string.IsNullOrWhiteSpace(q)) + return Results.BadRequest("Query parameter 'q' is required."); + + var servers = await service.SearchServersAsync(q); + return Results.Ok(servers); + }); + + group.MapGet("/{id:guid}", async (Guid id, ServerDirectoryService service) => + { + var server = await service.GetServerByIdAsync(id); + return server is null ? Results.NotFound() : Results.Ok(server); + }); + + group.MapPost("/", async (RegisterServerRequest request, ServerDirectoryService service) => + { + try + { + var server = await service.RegisterServerAsync(request); + return Results.Created($"/api/servers/{server.Id}", server); + } + catch (InvalidOperationException ex) + { + return Results.Conflict(new { error = ex.Message }); + } + }); + + group.MapPut("/{id:guid}/status", async (Guid id, ServerStatusDto status, ServerDirectoryService service) => + { + var server = await service.UpdateServerStatusAsync(id, status.OnlineUsers, status.TotalChannels); + return server is null ? Results.NotFound() : Results.Ok(server); + }); + + return app; + } +} diff --git a/src/EchoHub.Api/Services/ServerDirectoryService.cs b/src/EchoHub.Api/Services/ServerDirectoryService.cs new file mode 100644 index 0000000..c6dcf95 --- /dev/null +++ b/src/EchoHub.Api/Services/ServerDirectoryService.cs @@ -0,0 +1,90 @@ +using EchoHub.Api.Data; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Api.Services; + +public class ServerDirectoryService(ServerDirectoryDbContext db) +{ + public async Task> GetAllServersAsync() + { + return await db.Servers + .OrderByDescending(s => s.OnlineUsers) + .Select(s => new ServerInfoDto( + s.Id, s.Name, s.Description, s.Url, + s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt)) + .ToListAsync(); + } + + public async Task GetServerByIdAsync(Guid id) + { + var server = await db.Servers.FindAsync(id); + return server is null ? null : MapToDto(server); + } + + public async Task RegisterServerAsync(RegisterServerRequest request) + { + var exists = await db.Servers.AnyAsync(s => s.Url == request.Url); + if (exists) + throw new InvalidOperationException($"A server with URL '{request.Url}' is already registered."); + + var server = new ServerInfo + { + Id = Guid.NewGuid(), + Name = request.Name, + Description = request.Description, + Url = request.Url, + OnlineUsers = 0, + TotalChannels = 0, + RegisteredAt = DateTimeOffset.UtcNow, + LastPingAt = DateTimeOffset.UtcNow, + IsOnline = true + }; + + db.Servers.Add(server); + await db.SaveChangesAsync(); + + return MapToDto(server); + } + + public async Task UpdateServerStatusAsync(Guid id, int onlineUsers, int totalChannels) + { + var server = await db.Servers.FindAsync(id); + if (server is null) + return null; + + server.OnlineUsers = onlineUsers; + server.TotalChannels = totalChannels; + server.LastPingAt = DateTimeOffset.UtcNow; + server.IsOnline = true; + + await db.SaveChangesAsync(); + + return MapToDto(server); + } + + public async Task> SearchServersAsync(string query) + { + var lowerQuery = query.ToLowerInvariant(); + + return await db.Servers + .Where(s => s.Name.ToLower().Contains(lowerQuery) + || (s.Description != null && s.Description.ToLower().Contains(lowerQuery))) + .OrderByDescending(s => s.OnlineUsers) + .Select(s => new ServerInfoDto( + s.Id, s.Name, s.Description, s.Url, + s.OnlineUsers, s.TotalChannels, s.IsOnline, s.LastPingAt)) + .ToListAsync(); + } + + private static ServerInfoDto MapToDto(ServerInfo server) => new( + server.Id, + server.Name, + server.Description, + server.Url, + server.OnlineUsers, + server.TotalChannels, + server.IsOnline, + server.LastPingAt); +} diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs new file mode 100644 index 0000000..d880ee7 --- /dev/null +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -0,0 +1,224 @@ +using EchoHub.Core.Models; + +namespace EchoHub.Client.Commands; + +public record CommandResult(bool Handled, string? Message = null, bool IsError = false); + +public class CommandHandler +{ + public event Func? OnSetStatus; + public event Func? OnSetNick; + public event Func? OnSetColor; + public event Func? OnSetTheme; + public event Func? OnSendFile; + public event Func? OnOpenProfile; + public event Func? OnOpenServers; + public event Func? OnJoinChannel; + public event Func? OnLeaveChannel; + public event Func? OnSetTopic; + public event Func? OnListUsers; + public event Func? OnQuit; + public event Func? OnHelp; + + public bool IsCommand(string input) => input.StartsWith('/'); + + public async Task HandleAsync(string input) + { + if (!IsCommand(input)) + return new CommandResult(false); + + var parts = input[1..].Split(' ', 2, StringSplitOptions.TrimEntries); + var command = parts[0].ToLowerInvariant(); + var args = parts.Length > 1 ? parts[1] : string.Empty; + + return command switch + { + "status" => await HandleStatus(args), + "nick" => await HandleNick(args), + "color" => await HandleColor(args), + "theme" => await HandleTheme(args), + "send" => await HandleSend(args), + "profile" => await HandleProfile(), + "servers" => await HandleServers(), + "join" => await HandleJoin(args), + "leave" => await HandleLeave(), + "topic" => await HandleTopic(args), + "users" => await HandleUsers(), + "quit" or "exit" => await HandleQuit(), + "help" or "?" => await HandleHelp(), + _ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true), + }; + } + + private async Task HandleStatus(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /status or /status ", IsError: true); + + var statusArg = args.ToLowerInvariant().Trim(); + UserStatus? status = statusArg switch + { + "online" => UserStatus.Online, + "away" => UserStatus.Away, + "dnd" or "donotdisturb" => UserStatus.DoNotDisturb, + "invisible" => UserStatus.Invisible, + _ => null, + }; + + if (status.HasValue) + { + if (OnSetStatus is not null) + await OnSetStatus(status.Value, null); + return new CommandResult(true, $"Status set to {status.Value}"); + } + + // Treat as custom status message (keep current status) + if (OnSetStatus is not null) + await OnSetStatus(UserStatus.Online, args); + return new CommandResult(true, $"Status message set: {args}"); + } + + private async Task HandleNick(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /nick ", IsError: true); + + if (OnSetNick is not null) + await OnSetNick(args.Trim()); + return new CommandResult(true, $"Display name set to: {args.Trim()}"); + } + + private async Task HandleColor(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /color (e.g., /color #FF5733)", IsError: true); + + var color = args.Trim(); + if (!color.StartsWith('#')) + color = "#" + color; + + if (color.Length != 7 || !IsValidHex(color[1..])) + return new CommandResult(true, "Invalid color. Use hex format: #RRGGBB", IsError: true); + + if (OnSetColor is not null) + await OnSetColor(color); + return new CommandResult(true, $"Nickname color set to: {color}"); + } + + private async Task HandleTheme(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /theme (Default, Dark, Light, Hacker, Solarized)", IsError: true); + + if (OnSetTheme is not null) + await OnSetTheme(args.Trim()); + return new CommandResult(true, $"Theme switched to: {args.Trim()}"); + } + + private async Task HandleSend(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /send ", IsError: true); + + var target = args.Trim().Trim('"'); + + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) + { + if (OnSendFile is not null) + await OnSendFile(target); + var fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "download"; + return new CommandResult(true, $"Downloading & uploading: {fileName}..."); + } + + if (!File.Exists(target)) + return new CommandResult(true, $"File not found: {target}", IsError: true); + + if (OnSendFile is not null) + await OnSendFile(target); + return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); + } + + private async Task HandleProfile() + { + if (OnOpenProfile is not null) + await OnOpenProfile(); + return new CommandResult(true); + } + + private async Task HandleServers() + { + if (OnOpenServers is not null) + await OnOpenServers(); + return new CommandResult(true); + } + + private async Task HandleJoin(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /join ", IsError: true); + + var channel = args.Trim().TrimStart('#'); + if (OnJoinChannel is not null) + await OnJoinChannel(channel); + return new CommandResult(true); + } + + private async Task HandleLeave() + { + if (OnLeaveChannel is not null) + await OnLeaveChannel(); + return new CommandResult(true); + } + + private async Task HandleTopic(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /topic ", IsError: true); + + if (OnSetTopic is not null) + await OnSetTopic(args.Trim()); + return new CommandResult(true, $"Topic set to: {args.Trim()}"); + } + + private async Task HandleUsers() + { + if (OnListUsers is not null) + await OnListUsers(); + return new CommandResult(true); + } + + private async Task HandleQuit() + { + if (OnQuit is not null) + await OnQuit(); + return new CommandResult(true); + } + + private async Task HandleHelp() + { + if (OnHelp is not null) + await OnHelp(); + return new CommandResult(true, """ + Available commands: + /status - Set your status + /status - Set status message + /nick - Set display name + /color <#hex> - Set nickname color + /theme - Switch theme + /send - Send a file or image + /profile - Open your profile + /servers - Open saved servers + /join - Join a channel + /leave - Leave current channel + /topic - Set channel topic + /users - List online users + /quit - Exit the app + """); + } + + private static bool IsValidHex(string s) => + s.All(c => char.IsAsciiHexDigit(c)); +} diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs new file mode 100644 index 0000000..8fcfe07 --- /dev/null +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -0,0 +1,24 @@ +namespace EchoHub.Client.Config; + +public class ClientConfig +{ + public List SavedServers { get; set; } = []; + public AccountPreset DefaultPreset { get; set; } = new(); + public string ActiveTheme { get; set; } = "Default"; +} + +public class SavedServer +{ + public required string Name { get; set; } + public required string Url { get; set; } + public string? Username { get; set; } + public string? Token { get; set; } + public DateTimeOffset LastConnected { get; set; } +} + +public class AccountPreset +{ + public string? DisplayName { get; set; } + public string? Bio { get; set; } + public string? NicknameColor { get; set; } +} diff --git a/src/EchoHub.Client/Config/ConfigManager.cs b/src/EchoHub.Client/Config/ConfigManager.cs new file mode 100644 index 0000000..5c40071 --- /dev/null +++ b/src/EchoHub.Client/Config/ConfigManager.cs @@ -0,0 +1,70 @@ +using System.Text.Json; + +namespace EchoHub.Client.Config; + +public static class ConfigManager +{ + private static readonly string ConfigDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub"); + + private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static ClientConfig Load() + { + try + { + if (!File.Exists(ConfigPath)) + return new ClientConfig(); + + var json = File.ReadAllText(ConfigPath); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new ClientConfig(); + } + catch + { + return new ClientConfig(); + } + } + + public static void Save(ClientConfig config) + { + try + { + Directory.CreateDirectory(ConfigDir); + var json = JsonSerializer.Serialize(config, JsonOptions); + File.WriteAllText(ConfigPath, json); + } + catch + { + // Silently fail — config save is best-effort + } + } + + public static void SaveServer(SavedServer server) + { + var config = Load(); + var existing = config.SavedServers.FindIndex(s => + string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase)); + + if (existing >= 0) + config.SavedServers[existing] = server; + else + config.SavedServers.Add(server); + + Save(config); + } + + public static void RemoveServer(string url) + { + var config = Load(); + config.SavedServers.RemoveAll(s => + string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase)); + + Save(config); + } +} diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj new file mode 100644 index 0000000..7a9754d --- /dev/null +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs new file mode 100644 index 0000000..3d2cd1f --- /dev/null +++ b/src/EchoHub.Client/Program.cs @@ -0,0 +1,717 @@ +using EchoHub.Client.Commands; +using EchoHub.Client.Config; +using EchoHub.Client.Services; +using EchoHub.Client.Themes; +using EchoHub.Client.UI; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Serilog; +using Terminal.Gui.App; +using Terminal.Gui.Views; + +namespace EchoHub.Client; + +public static class Program +{ + private static IApplication _app = null!; + private static EchoHubConnection? _connection; + private static ApiClient? _apiClient; + private static MainWindow? _mainWindow; + private static CommandHandler? _commandHandler; + private static ClientConfig _config = new(); + private static UserStatus _currentStatus = UserStatus.Online; + private static string? _currentStatusMessage; + private static string _currentUsername = string.Empty; + + public static void Main() + { + // Configure Serilog file logger + var logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "logs"); + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.File( + Path.Combine(logDir, "echohub-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + Log.Information("EchoHub client starting"); + + try + { + // Load configuration + _config = ConfigManager.Load(); + Log.Information("Configuration loaded, active theme: {Theme}", _config.ActiveTheme); + + _app = Application.Create().Init(); + + // Apply our custom color scheme + var theme = Themes.ThemeManager.GetTheme(_config.ActiveTheme); + Themes.ThemeManager.ApplyTheme(theme); + + _mainWindow = new MainWindow(_app); + _commandHandler = new CommandHandler(); + + // Wire MainWindow events + _mainWindow.OnConnectRequested += HandleConnect; + _mainWindow.OnDisconnectRequested += HandleDisconnect; + _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; + _mainWindow.OnChannelSelected += HandleChannelSelected; + _mainWindow.OnProfileRequested += HandleProfileRequested; + _mainWindow.OnStatusRequested += HandleStatusRequested; + _mainWindow.OnThemeSelected += HandleThemeSelected; + _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; + + // Wire CommandHandler events + WireCommandHandlerEvents(); + + _mainWindow.UpdateStatusBar("Disconnected"); + + _app.Run(_mainWindow); + _app.Dispose(); + + // Cleanup + _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _apiClient?.Dispose(); + } + catch (Exception ex) + { + Log.Fatal(ex, "EchoHub client crashed"); + throw; + } + finally + { + Log.Information("EchoHub client shutting down"); + Log.CloseAndFlush(); + } + } + + // ── Command Handler Wiring ───────────────────────────────────────────── + + private static void WireCommandHandlerEvents() + { + _commandHandler!.OnSetStatus += async (status, message) => + { + if (_connection is null || !_connection.IsConnected) + return; + + await _connection.UpdateStatusAsync(status, message); + _currentStatus = status; + _currentStatusMessage = message; + }; + + _commandHandler.OnSetNick += async (displayName) => + { + if (_apiClient is null) + return; + + await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName)); + _app.Invoke(() => + { + _mainWindow!.SetCurrentUser(displayName); + _mainWindow.UpdateStatusBar("Connected"); + }); + }; + + _commandHandler.OnSetColor += async (color) => + { + if (_apiClient is null) + return; + + await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color)); + }; + + _commandHandler.OnSetTheme += (name) => + { + _app.Invoke(() => HandleThemeSelected(name)); + return Task.CompletedTask; + }; + + _commandHandler.OnSendFile += async (target) => + { + if (_apiClient is null || _connection is null || !_connection.IsConnected) + return; + + var channel = _mainWindow!.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + return; + + try + { + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) + { + // Download from URL then upload + Log.Information("Downloading file from {Url}", target); + using var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(uri); + response.EnsureSuccessStatusCode(); + + var fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) + { + // Try to infer extension from Content-Type + var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; + var ext = contentType switch + { + "image/png" => ".png", + "image/jpeg" => ".jpg", + "image/gif" => ".gif", + "image/webp" => ".webp", + _ => "" + }; + fileName = $"download{ext}"; + } + + await using var stream = await response.Content.ReadAsStreamAsync(); + await _apiClient.UploadFileAsync(channel, stream, fileName); + Log.Information("URL file uploaded: {FileName}", fileName); + } + else + { + // Local file + await using var stream = File.OpenRead(target); + var fileName = Path.GetFileName(target); + await _apiClient.UploadFileAsync(channel, stream, fileName); + } + } + catch (Exception ex) + { + Log.Error(ex, "File upload failed for {Target}", target); + _app.Invoke(() => + _mainWindow!.ShowError($"File upload failed: {ex.Message}")); + } + }; + + _commandHandler.OnOpenProfile += () => + { + _app.Invoke(HandleProfileRequested); + return Task.CompletedTask; + }; + + _commandHandler.OnOpenServers += () => + { + _app.Invoke(HandleSavedServersRequested); + return Task.CompletedTask; + }; + + _commandHandler.OnJoinChannel += async (channelName) => + { + if (_connection is null || !_connection.IsConnected) + return; + + try + { + var history = await _connection.JoinChannelAsync(channelName); + _app.Invoke(() => + { + _mainWindow!.SwitchToChannel(channelName); + if (history.Count > 0) + _mainWindow.LoadHistory(channelName, history); + }); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Failed to join channel: {ex.Message}")); + } + }; + + _commandHandler.OnLeaveChannel += async () => + { + if (_connection is null || !_connection.IsConnected) + return; + + var channel = _mainWindow!.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + return; + + try + { + await _connection.LeaveChannelAsync(channel); + _app.Invoke(() => + _mainWindow!.AddSystemMessage(channel, $"You left #{channel}")); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Failed to leave channel: {ex.Message}")); + } + }; + + _commandHandler.OnSetTopic += async (topic) => + { + // Topic setting would go through an API endpoint if available. + // For now, show as a system message. + await Task.CompletedTask; + _app.Invoke(() => + { + var channel = _mainWindow!.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}"); + }); + }; + + _commandHandler.OnListUsers += async () => + { + if (_connection is null || !_connection.IsConnected) + return; + + var channel = _mainWindow!.CurrentChannel; + if (string.IsNullOrEmpty(channel)) + return; + + try + { + var users = await _connection.GetOnlineUsersAsync(channel); + _app.Invoke(() => + { + _mainWindow!.AddSystemMessage(channel, $"Online users in #{channel}:"); + foreach (var user in users) + { + var displayName = user.DisplayName ?? user.Username; + var statusText = user.Status.ToString(); + if (!string.IsNullOrWhiteSpace(user.StatusMessage)) + statusText += $" - {user.StatusMessage}"; + _mainWindow.AddSystemMessage(channel, $" {displayName} ({statusText})"); + } + }); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Failed to list users: {ex.Message}")); + } + }; + + _commandHandler.OnQuit += () => + { + _app.Invoke(() => _app.RequestStop()); + return Task.CompletedTask; + }; + + // OnHelp is handled by CommandHandler returning help text — no additional wiring needed. + } + + // ── MainWindow Event Handlers ────────────────────────────────────────── + + private static void HandleConnect() + { + var result = ConnectDialog.Show(_app, _config.SavedServers); + if (result is null) + return; + + Log.Information("Connecting to {Url} as {User} (register={IsRegister})", + result.ServerUrl, result.Username, result.IsRegister); + + Task.Run(async () => + { + try + { + _apiClient?.Dispose(); + _apiClient = new ApiClient(result.ServerUrl); + + _app.Invoke(() => + _mainWindow!.UpdateStatusBar("Authenticating...")); + + LoginResponse loginResponse; + if (result.IsRegister) + { + loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password); + } + else + { + loginResponse = await _apiClient.LoginAsync(result.Username, result.Password); + } + + _currentUsername = loginResponse.Username; + + _app.Invoke(() => + { + _mainWindow!.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username); + _mainWindow.UpdateStatusBar("Authenticated, connecting..."); + }); + + // Dispose previous connection if any + if (_connection is not null) + { + await _connection.DisposeAsync(); + } + + _connection = new EchoHubConnection(result.ServerUrl, _apiClient.Token!); + WireConnectionEvents(_connection); + + await _connection.ConnectAsync(); + + // Load channels + var channels = await _apiClient.GetChannelsAsync(); + _app.Invoke(() => + { + _mainWindow!.SetChannels(channels); + _mainWindow.UpdateStatusBar("Connected"); + }); + + // Join the default channel + await _connection.JoinChannelAsync(HubConstants.DefaultChannel); + + _app.Invoke(() => + _mainWindow!.SwitchToChannel(HubConstants.DefaultChannel)); + + // Load history for default channel + try + { + var history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + _app.Invoke(() => + { + _mainWindow!.LoadHistory(HubConstants.DefaultChannel, history); + _mainWindow.FocusInput(); + }); + } + catch + { + // History might not be available, that is okay + } + + // Save server to config on successful connection + var savedServer = new SavedServer + { + Name = new Uri(result.ServerUrl).Host, + Url = result.ServerUrl, + Username = result.Username, + Token = _apiClient.Token, + LastConnected = DateTimeOffset.Now + }; + ConfigManager.SaveServer(savedServer); + _config = ConfigManager.Load(); + Log.Information("Connected successfully to {Url}", result.ServerUrl); + } + catch (Exception ex) + { + Log.Error(ex, "Connection failed to {Url}", result.ServerUrl); + _app.Invoke(() => + { + _mainWindow!.ShowError($"Connection failed: {ex.Message}"); + _mainWindow.UpdateStatusBar("Disconnected"); + }); + } + }); + } + + private static void HandleDisconnect() + { + Log.Information("Disconnecting from server"); + Task.Run(async () => + { + try + { + if (_connection is not null) + { + await _connection.DisconnectAsync(); + await _connection.DisposeAsync(); + _connection = null; + } + + _apiClient?.Dispose(); + _apiClient = null; + + _app.Invoke(() => + { + _mainWindow!.ClearAll(); + _mainWindow.UpdateStatusBar("Disconnected"); + }); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Disconnect error: {ex.Message}")); + } + }); + } + + private static void HandleMessageSubmitted(string channelName, string content) + { + if (_connection is null || !_connection.IsConnected) + { + _mainWindow!.ShowError("Not connected to a server."); + return; + } + + // Check if this is a command + if (_commandHandler!.IsCommand(content)) + { + Task.Run(async () => + { + try + { + var result = await _commandHandler.HandleAsync(content); + if (result.Message is not null) + { + _app.Invoke(() => + { + if (result.IsError) + _mainWindow!.ShowError(result.Message); + else + _mainWindow!.AddSystemMessage(channelName, result.Message); + }); + } + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Command failed: {ex.Message}")); + } + }); + return; + } + + // Regular message + Task.Run(async () => + { + try + { + await _connection.SendMessageAsync(channelName, content); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Send failed: {ex.Message}")); + } + }); + } + + private static void HandleChannelSelected(string channelName) + { + if (_connection is null || !_connection.IsConnected) + return; + + Task.Run(async () => + { + try + { + await _connection.JoinChannelAsync(channelName); + + // Load history if the channel has no messages cached yet + try + { + var history = await _connection.GetHistoryAsync(channelName); + _app.Invoke(() => + _mainWindow!.LoadHistory(channelName, history)); + } + catch + { + // History might not be available + } + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Failed to join channel: {ex.Message}")); + } + }); + } + + private static void HandleProfileRequested() + { + Task.Run(async () => + { + UserProfileDto? profile = null; + try + { + if (_apiClient is not null && !string.IsNullOrEmpty(_currentUsername)) + { + profile = await _apiClient.GetUserProfileAsync(_currentUsername); + } + } + catch + { + // Profile may not be available; continue with null + } + + _app.Invoke(() => + { + var action = UserPanelDialog.Show(_app, + profile, + _config.SavedServers, + _currentStatus, + _currentStatusMessage); + + switch (action) + { + case UserPanelAction.EditProfile: + HandleEditProfile(profile); + break; + case UserPanelAction.SetStatus: + HandleStatusRequested(); + break; + case UserPanelAction.Close: + default: + break; + } + }); + }); + } + + private static void HandleEditProfile(UserProfileDto? currentProfile) + { + var editResult = ProfileEditDialog.Show(_app, + currentProfile?.DisplayName, + currentProfile?.Bio, + currentProfile?.NicknameColor); + + if (editResult is null) + return; + + Task.Run(async () => + { + try + { + if (_apiClient is not null) + { + await _apiClient.UpdateProfileAsync(new UpdateProfileRequest( + editResult.DisplayName, + editResult.Bio, + editResult.NicknameColor)); + + if (editResult.DisplayName is not null) + { + _app.Invoke(() => + { + _mainWindow!.SetCurrentUser(editResult.DisplayName); + _mainWindow.UpdateStatusBar("Connected"); + }); + } + + // Update local config preset + _config.DefaultPreset = new AccountPreset + { + DisplayName = editResult.DisplayName, + Bio = editResult.Bio, + NicknameColor = editResult.NicknameColor + }; + ConfigManager.Save(_config); + } + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Profile update failed: {ex.Message}")); + } + }); + } + + private static void HandleStatusRequested() + { + var result = StatusDialog.Show(_app, _currentStatus, _currentStatusMessage); + if (result is null) + return; + + _currentStatus = result.Status; + _currentStatusMessage = result.StatusMessage; + + if (_connection is not null && _connection.IsConnected) + { + Task.Run(async () => + { + try + { + await _connection.UpdateStatusAsync(result.Status, result.StatusMessage); + } + catch (Exception ex) + { + _app.Invoke(() => + _mainWindow!.ShowError($"Status update failed: {ex.Message}")); + } + }); + } + } + + private static void HandleThemeSelected(string themeName) + { + Log.Information("Theme selected: {Theme}", themeName); + + var theme = Themes.ThemeManager.GetTheme(themeName); + Themes.ThemeManager.ApplyTheme(theme); + + _config.ActiveTheme = themeName; + ConfigManager.Save(_config); + + // Defer UI refresh so the menu finishes processing its click first + _app.Invoke(() => + { + _mainWindow!.ApplyColorSchemes(); + _mainWindow.SetNeedsDraw(); + Log.Debug("Theme applied and UI refreshed"); + }); + } + + private static void HandleSavedServersRequested() + { + if (_config.SavedServers.Count == 0) + { + MessageBox.Query(_app, "Saved Servers", "No saved servers yet.\nConnect to a server to save it automatically.", "OK"); + return; + } + + var serverLines = _config.SavedServers + .Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}") + .ToList(); + + var message = string.Join("\n", serverLines); + MessageBox.Query(_app, "Saved Servers", message, "OK"); + } + + // ── Connection Event Wiring ──────────────────────────────────────────── + + private static void WireConnectionEvents(EchoHubConnection connection) + { + connection.OnMessageReceived += message => + { + _app.Invoke(() => + _mainWindow!.AddMessage(message)); + }; + + connection.OnUserJoined += (channelName, username) => + { + _app.Invoke(() => + _mainWindow!.AddSystemMessage(channelName, $"{username} joined the channel")); + }; + + connection.OnUserLeft += (channelName, username) => + { + _app.Invoke(() => + _mainWindow!.AddSystemMessage(channelName, $"{username} left the channel")); + }; + + connection.OnUserStatusChanged += presence => + { + _app.Invoke(() => + { + var displayName = presence.DisplayName ?? presence.Username; + var statusText = presence.Status.ToString(); + if (!string.IsNullOrWhiteSpace(presence.StatusMessage)) + statusText += $" - {presence.StatusMessage}"; + + // Show status change in all active channels + foreach (var channelName in _mainWindow!.GetChannelNames()) + { + _mainWindow.AddStatusMessage(channelName, displayName, statusText); + } + }); + }; + + connection.OnError += errorMessage => + { + _app.Invoke(() => + _mainWindow!.ShowError(errorMessage)); + }; + + connection.OnConnectionStateChanged += status => + { + _app.Invoke(() => + _mainWindow!.UpdateStatusBar(status)); + }; + } +} diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs new file mode 100644 index 0000000..23d08f2 --- /dev/null +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -0,0 +1,172 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using EchoHub.Core.DTOs; + +namespace EchoHub.Client.Services; + +public sealed class ApiClient : IDisposable +{ + private readonly HttpClient _http; + private string? _token; + + public string? Token => _token; + public string BaseUrl { get; } + + public ApiClient(string baseUrl) + { + BaseUrl = baseUrl.TrimEnd('/'); + _http = new HttpClient + { + BaseAddress = new Uri(BaseUrl) + }; + } + + public async Task RegisterAsync(string username, string password, string? displayName = null) + { + var request = new RegisterRequest(username, password, displayName); + var response = await _http.PostAsJsonAsync("/api/auth/register", request); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Registration returned empty response."); + + SetToken(result.Token); + return result; + } + + public async Task LoginAsync(string username, string password) + { + var request = new LoginRequest(username, password); + var response = await _http.PostAsJsonAsync("/api/auth/login", request); + await EnsureSuccessAsync(response); + + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Login returned empty response."); + + SetToken(result.Token); + return result; + } + + public async Task> GetChannelsAsync() + { + EnsureAuthenticated(); + var channels = await _http.GetFromJsonAsync>("/api/channels"); + return channels ?? []; + } + + public async Task GetServerInfoAsync() + { + var info = await _http.GetFromJsonAsync("/api/server/info"); + return info; + } + + public async Task GetUserProfileAsync(string username) + { + EnsureAuthenticated(); + var profile = await _http.GetFromJsonAsync($"/api/users/{Uri.EscapeDataString(username)}/profile"); + return profile; + } + + public async Task UpdateProfileAsync(UpdateProfileRequest request) + { + EnsureAuthenticated(); + var response = await _http.PutAsJsonAsync("/api/users/profile", request); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + public async Task UploadAvatarAsync(Stream imageStream, string fileName) + { + EnsureAuthenticated(); + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(imageStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); + content.Add(streamContent, "file", fileName); + + var response = await _http.PostAsync("/api/users/avatar", content); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + return result?.AsciiArt; + } + + public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName) + { + EnsureAuthenticated(); + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); + content.Add(streamContent, "file", fileName); + + var response = await _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + private void SetToken(string token) + { + _token = token; + _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response) + { + if (response.IsSuccessStatusCode) + return; + + // Try to extract a meaningful error message from the response body + var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}"; + try + { + var body = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(body)) + { + // Try to parse {"error": "..."} format + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var errorProp) || + doc.RootElement.TryGetProperty("Error", out errorProp)) + { + errorMessage = errorProp.GetString() ?? errorMessage; + } + else + { + errorMessage = body; + } + } + } + catch + { + // If we can't parse the body, use the status code message + } + + throw new HttpRequestException(errorMessage); + } + + private void EnsureAuthenticated() + { + if (string.IsNullOrEmpty(_token)) + throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first."); + } + + private static string GetContentType(string fileName) + { + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + return ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".txt" => "text/plain", + ".pdf" => "application/pdf", + _ => "application/octet-stream", + }; + } + + public void Dispose() + { + _http.Dispose(); + } +} + +internal record AvatarUploadResponse(string AsciiArt); diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs new file mode 100644 index 0000000..f830185 --- /dev/null +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -0,0 +1,135 @@ +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Microsoft.AspNetCore.SignalR.Client; + +namespace EchoHub.Client.Services; + +public sealed class EchoHubConnection : IAsyncDisposable +{ + private readonly HubConnection _connection; + + public event Action? OnMessageReceived; + public event Action? OnUserJoined; + public event Action? OnUserLeft; + public event Action? OnChannelUpdated; + public event Action? OnUserStatusChanged; + public event Action? OnError; + public event Action? OnConnectionStateChanged; + + public bool IsConnected => _connection.State == HubConnectionState.Connected; + + public EchoHubConnection(string serverUrl, string jwtToken) + { + var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath; + + _connection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(jwtToken); + }) + .WithAutomaticReconnect() + .Build(); + + RegisterHandlers(); + + _connection.Reconnecting += _ => + { + OnConnectionStateChanged?.Invoke("Reconnecting..."); + return Task.CompletedTask; + }; + + _connection.Reconnected += _ => + { + OnConnectionStateChanged?.Invoke("Connected"); + return Task.CompletedTask; + }; + + _connection.Closed += _ => + { + OnConnectionStateChanged?.Invoke("Disconnected"); + return Task.CompletedTask; + }; + } + + private void RegisterHandlers() + { + _connection.On(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message => + { + OnMessageReceived?.Invoke(message); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) => + { + OnUserJoined?.Invoke(channelName, username); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) => + { + OnUserLeft?.Invoke(channelName, username); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.ChannelUpdated), channel => + { + OnChannelUpdated?.Invoke(channel); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.UserStatusChanged), presence => + { + OnUserStatusChanged?.Invoke(presence); + }); + + _connection.On(nameof(Core.Contracts.IEchoHubClient.Error), message => + { + OnError?.Invoke(message); + }); + } + + public async Task ConnectAsync() + { + OnConnectionStateChanged?.Invoke("Connecting..."); + await _connection.StartAsync(); + OnConnectionStateChanged?.Invoke("Connected"); + } + + public async Task DisconnectAsync() + { + await _connection.StopAsync(); + OnConnectionStateChanged?.Invoke("Disconnected"); + } + + public async Task> JoinChannelAsync(string channelName) + { + return await _connection.InvokeAsync>("JoinChannel", channelName); + } + + public async Task LeaveChannelAsync(string channelName) + { + await _connection.InvokeAsync("LeaveChannel", channelName); + } + + public async Task SendMessageAsync(string channelName, string content) + { + await _connection.InvokeAsync("SendMessage", channelName, content); + } + + public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount) + { + return await _connection.InvokeAsync>("GetChannelHistory", channelName, count); + } + + public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null) + { + await _connection.InvokeAsync("UpdateStatus", status, statusMessage); + } + + public async Task> GetOnlineUsersAsync(string channelName) + { + return await _connection.InvokeAsync>("GetOnlineUsers", channelName); + } + + public async ValueTask DisposeAsync() + { + await _connection.DisposeAsync(); + } +} diff --git a/src/EchoHub.Client/Themes/Theme.cs b/src/EchoHub.Client/Themes/Theme.cs new file mode 100644 index 0000000..75a7965 --- /dev/null +++ b/src/EchoHub.Client/Themes/Theme.cs @@ -0,0 +1,18 @@ +namespace EchoHub.Client.Themes; + +public class Theme +{ + public required string Name { get; set; } + public ThemeColors Base { get; set; } = new(); + public ThemeColors Menu { get; set; } = new(); + public ThemeColors Dialog { get; set; } = new(); + public ThemeColors Status { get; set; } = new(); +} + +public class ThemeColors +{ + public string Foreground { get; set; } = "White"; + public string Background { get; set; } = "Black"; + public string FocusForeground { get; set; } = "White"; + public string FocusBackground { get; set; } = "Blue"; +} diff --git a/src/EchoHub.Client/Themes/ThemeManager.cs b/src/EchoHub.Client/Themes/ThemeManager.cs new file mode 100644 index 0000000..6d5f0c3 --- /dev/null +++ b/src/EchoHub.Client/Themes/ThemeManager.cs @@ -0,0 +1,317 @@ +using System.Text.Json; +using Terminal.Gui.Drawing; +using Terminal.Gui.Configuration; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.Themes; + +public static class ThemeManager +{ + private static readonly string ThemeDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "themes"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static readonly Theme DefaultTheme = new() + { + Name = "Default", + Base = new ThemeColors + { + Foreground = "Gray", + Background = "Black", + FocusForeground = "White", + FocusBackground = "DarkGray" + }, + Menu = new ThemeColors + { + Foreground = "Gray", + Background = "Black", + FocusForeground = "White", + FocusBackground = "DarkGray" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "Black", + FocusBackground = "Gray" + }, + Status = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "White", + FocusBackground = "DarkGray" + } + }; + + private static readonly Theme ClassicTheme = new() + { + Name = "Classic", + Base = new ThemeColors + { + Foreground = "White", + Background = "Blue", + FocusForeground = "Black", + FocusBackground = "Cyan" + }, + Menu = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "White", + FocusBackground = "Black" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "Black", + FocusBackground = "Cyan" + }, + Status = new ThemeColors + { + Foreground = "White", + Background = "DarkGray", + FocusForeground = "White", + FocusBackground = "DarkGray" + } + }; + + private static readonly Theme LightTheme = new() + { + Name = "Light", + Base = new ThemeColors + { + Foreground = "Black", + Background = "White", + FocusForeground = "White", + FocusBackground = "Blue" + }, + Menu = new ThemeColors + { + Foreground = "White", + Background = "Blue", + FocusForeground = "BrightYellow", + FocusBackground = "Blue" + }, + Dialog = new ThemeColors + { + Foreground = "Black", + Background = "White", + FocusForeground = "White", + FocusBackground = "Blue" + }, + Status = new ThemeColors + { + Foreground = "White", + Background = "Blue", + FocusForeground = "White", + FocusBackground = "Blue" + } + }; + + private static readonly Theme HackerTheme = new() + { + Name = "Hacker", + Base = new ThemeColors + { + Foreground = "BrightGreen", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "Green" + }, + Menu = new ThemeColors + { + Foreground = "BrightGreen", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "BrightGreen" + }, + Dialog = new ThemeColors + { + Foreground = "BrightGreen", + Background = "Black", + FocusForeground = "Black", + FocusBackground = "Green" + }, + Status = new ThemeColors + { + Foreground = "BrightGreen", + Background = "Black", + FocusForeground = "BrightGreen", + FocusBackground = "Black" + } + }; + + private static readonly Theme SolarizedTheme = new() + { + Name = "Solarized", + Base = new ThemeColors + { + Foreground = "Cyan", + Background = "Black", + FocusForeground = "BrightYellow", + FocusBackground = "DarkGray" + }, + Menu = new ThemeColors + { + Foreground = "BrightCyan", + Background = "DarkGray", + FocusForeground = "BrightYellow", + FocusBackground = "Black" + }, + Dialog = new ThemeColors + { + Foreground = "Cyan", + Background = "DarkGray", + FocusForeground = "BrightYellow", + FocusBackground = "Black" + }, + Status = new ThemeColors + { + Foreground = "BrightCyan", + Background = "DarkGray", + FocusForeground = "BrightCyan", + FocusBackground = "DarkGray" + } + }; + + private static readonly Theme TransparentTheme = new() + { + Name = "Transparent", + Base = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "BrightCyan", + FocusBackground = "Black" + }, + Menu = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "BrightCyan", + FocusBackground = "Black" + }, + Dialog = new ThemeColors + { + Foreground = "White", + Background = "Black", + FocusForeground = "BrightCyan", + FocusBackground = "Black" + }, + Status = new ThemeColors + { + Foreground = "Gray", + Background = "Black", + FocusForeground = "Gray", + FocusBackground = "Black" + } + }; + + private static readonly List BuiltInThemes = + [ + DefaultTheme, + TransparentTheme, + ClassicTheme, + LightTheme, + HackerTheme, + SolarizedTheme + ]; + + public static List GetAvailableThemes() + { + var themes = new List(BuiltInThemes); + + try + { + if (Directory.Exists(ThemeDir)) + { + foreach (var file in Directory.GetFiles(ThemeDir, "*.json")) + { + try + { + var json = File.ReadAllText(file); + var theme = JsonSerializer.Deserialize(json, JsonOptions); + if (theme is not null && !string.IsNullOrWhiteSpace(theme.Name)) + { + // Skip if a built-in theme already has this name + if (!themes.Exists(t => string.Equals(t.Name, theme.Name, StringComparison.OrdinalIgnoreCase))) + { + themes.Add(theme); + } + } + } + catch + { + // Skip malformed theme files + } + } + } + } + catch + { + // If we can't read the theme directory, just return built-ins + } + + return themes; + } + + public static Theme GetTheme(string name) + { + var themes = GetAvailableThemes(); + return themes.Find(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? DefaultTheme; + } + + public static void ApplyTheme(Theme theme) + { + SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base)); + SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu)); + SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog)); + } + + public static void SaveTheme(Theme theme) + { + try + { + Directory.CreateDirectory(ThemeDir); + var fileName = $"{theme.Name}.json"; + var filePath = Path.Combine(ThemeDir, fileName); + var json = JsonSerializer.Serialize(theme, JsonOptions); + File.WriteAllText(filePath, json); + } + catch + { + // Silently fail — theme save is best-effort + } + } + + private static Scheme BuildColorScheme(ThemeColors colors) + { + var normal = new Attribute(ParseColor(colors.Foreground), ParseColor(colors.Background)); + var focus = new Attribute(ParseColor(colors.FocusForeground), ParseColor(colors.FocusBackground)); + + return new Scheme + { + Normal = normal, + Focus = focus, + HotNormal = normal, + HotFocus = focus, + Disabled = normal + }; + } + + private static Color ParseColor(string colorName) + { + if (Color.TryParse(colorName, out var color)) + return color ?? Color.White; + + return Color.White; + } +} diff --git a/src/EchoHub.Client/UI/ConnectDialog.cs b/src/EchoHub.Client/UI/ConnectDialog.cs new file mode 100644 index 0000000..614911d --- /dev/null +++ b/src/EchoHub.Client/UI/ConnectDialog.cs @@ -0,0 +1,228 @@ +using System.Collections.ObjectModel; +using EchoHub.Client.Config; +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; + +namespace EchoHub.Client.UI; + +/// +/// Result returned from the connect dialog. +/// +public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister); + +/// +/// A Terminal.Gui dialog for entering server connection and authentication details. +/// Includes a saved servers selector when saved servers are available. +/// +public sealed class ConnectDialog +{ + /// + /// Shows the connect dialog with an optional list of saved servers. + /// Returns the result, or null if cancelled. + /// + public static ConnectDialogResult? Show(IApplication app, List? savedServers = null) + { + ConnectDialogResult? result = null; + savedServers ??= []; + + var hasSavedServers = savedServers.Count > 0; + var dialogHeight = hasSavedServers ? 20 : 16; + + var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight }; + + int yOffset = 0; + + // -- Saved Servers section (if any) ----------------------------------- + ListView? savedServerList = null; + if (hasSavedServers) + { + var savedLabel = new Label + { + Text = "Saved Servers:", + X = 1, + Y = 1 + }; + dialog.Add(savedLabel); + + var serverDisplayNames = savedServers + .Select(s => $"{s.Name} ({s.Username ?? "?"})") + .ToList(); + + savedServerList = new ListView + { + Source = new ListWrapper(new ObservableCollection(serverDisplayNames)), + X = 1, + Y = 2, + Width = Dim.Fill(2), + Height = 3 + }; + dialog.Add(savedServerList); + + // Visual separator + var separator = new Label + { + Text = new string('-', 56), + X = 1, + Y = 5 + }; + dialog.Add(separator); + + yOffset = 5; + } + + // -- Manual entry fields ---------------------------------------------- + var urlLabel = new Label + { + Text = "Server URL:", + X = 1, + Y = yOffset + 1 + }; + var urlField = new TextField + { + Text = "http://localhost:5000", + X = 15, + Y = yOffset + 1, + Width = Dim.Fill(2) + }; + + var userLabel = new Label + { + Text = "Username:", + X = 1, + Y = yOffset + 3 + }; + var userField = new TextField + { + Text = "", + X = 15, + Y = yOffset + 3, + Width = Dim.Fill(2) + }; + + var passLabel = new Label + { + Text = "Password:", + X = 1, + Y = yOffset + 5 + }; + var passField = new TextField + { + Text = "", + X = 15, + Y = yOffset + 5, + Width = Dim.Fill(2), + Secret = true + }; + + var displayLabel = new Label + { + Text = "Display Name:", + X = 1, + Y = yOffset + 7 + }; + var displayField = new TextField + { + Text = "", + X = 15, + Y = yOffset + 7, + Width = Dim.Fill(2) + }; + + var loginButton = new Button + { + Text = "Login", + IsDefault = true, + X = Pos.Center() - 20, + Y = yOffset + 9 + }; + + var registerButton = new Button + { + Text = "Register", + X = Pos.Center() - 5, + Y = yOffset + 9 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 10, + Y = yOffset + 9 + }; + + // Wire saved server selection to auto-fill fields + if (savedServerList is not null && savedServers.Count > 0) + { + savedServerList.ValueChanged += (sender, e) => + { + var index = e.NewValue; + if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) + { + var server = savedServers[index.Value]; + urlField.Text = server.Url; + userField.Text = server.Username ?? ""; + } + }; + + // Pre-fill with the first saved server + urlField.Text = savedServers[0].Url; + userField.Text = savedServers[0].Username ?? ""; + } + + loginButton.Accepting += (s, e) => + { + var url = urlField.Text?.Trim() ?? string.Empty; + var user = userField.Text?.Trim() ?? string.Empty; + var pass = passField.Text ?? string.Empty; + + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + { + MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); + e.Handled = true; + return; + } + + result = new ConnectDialogResult(url, user, pass, IsRegister: false); + e.Handled = true; + app.RequestStop(); + }; + + registerButton.Accepting += (s, e) => + { + var url = urlField.Text?.Trim() ?? string.Empty; + var user = userField.Text?.Trim() ?? string.Empty; + var pass = passField.Text ?? string.Empty; + + if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) + { + MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); + e.Handled = true; + return; + } + + result = new ConnectDialogResult(url, user, pass, IsRegister: true); + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, + displayLabel, displayField, loginButton, registerButton, cancelButton); + + if (hasSavedServers && savedServerList is not null) + savedServerList.SetFocus(); + else + urlField.SetFocus(); + + app.Run(dialog); + + return result; + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs new file mode 100644 index 0000000..90b900f --- /dev/null +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -0,0 +1,511 @@ +using System.Collections.ObjectModel; +using EchoHub.Client.Themes; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Drawing; +using Terminal.Gui.Configuration; +using Terminal.Gui.Input; + +namespace EchoHub.Client.UI; + +/// +/// Main Terminal.Gui window for the EchoHub chat client. +/// +public sealed class MainWindow : Window +{ + private readonly IApplication _app; + private readonly ListView _channelList; + private readonly ListView _messageList; + private readonly TextView _inputField; + private readonly FrameView _chatFrame; + private readonly Label _statusLabel; + private MenuBar _menuBar; + + private readonly List _channelNames = []; + private readonly Dictionary> _channelMessages = []; + private string _currentChannel = string.Empty; + private string _currentUser = string.Empty; + + /// + /// Fired when the user selects a channel. Parameter is the channel name. + /// + public event Action? OnChannelSelected; + + /// + /// Fired when the user presses Enter in the input field. Parameters: channel name, message content. + /// + public event Action? OnMessageSubmitted; + + /// + /// Fired when the user requests to connect via the menu. + /// + public event Action? OnConnectRequested; + + /// + /// Fired when the user requests to disconnect via the menu. + /// + public event Action? OnDisconnectRequested; + + /// + /// Fired when the user requests to open their profile panel. + /// + public event Action? OnProfileRequested; + + /// + /// Fired when the user requests to set their status. + /// + public event Action? OnStatusRequested; + + /// + /// Fired when the user selects a theme from the menu. Parameter is the theme name. + /// + public event Action? OnThemeSelected; + + /// + /// Fired when the user requests to view saved servers. + /// + public event Action? OnSavedServersRequested; + + public MainWindow(IApplication app) + { + _app = app; + Title = "EchoHub"; + BorderStyle = LineStyle.None; + + // Menu bar at the top + _menuBar = BuildMenuBar(); + Add(_menuBar); + + // Left panel - channels + var channelsFrame = new FrameView + { + Title = "Channels", + X = 0, + Y = 1, // below menu bar + Width = 25, + Height = Dim.Fill(1) // leave room for status bar + }; + + _channelList = new ListView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }; + _channelList.SetSource(new ObservableCollection(_channelNames)); + _channelList.ValueChanged += OnChannelListSelectionChanged; + channelsFrame.Add(_channelList); + Add(channelsFrame); + + // Center panel - messages + _chatFrame = new FrameView + { + Title = "Chat", + X = 25, + Y = 1, // below menu bar + Width = Dim.Fill(), + Height = Dim.Fill(6) // leave room for input area and status bar + }; + + _messageList = new ListView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill() + }; + _messageList.SetSource(new ObservableCollection(new List())); + _chatFrame.Add(_messageList); + Add(_chatFrame); + + // Bottom input area (multiline) + var inputFrame = new FrameView + { + Title = "Message (Enter=send, Shift+Enter=newline)", + X = 25, + Y = Pos.Bottom(_chatFrame), + Width = Dim.Fill(), + Height = 5 + }; + + _inputField = new TextView + { + X = 0, + Y = 0, + Width = Dim.Fill(), + Height = Dim.Fill(), + Text = "", + WordWrap = true + }; + _inputField.KeyDown += OnInputKeyDown; + inputFrame.Add(_inputField); + Add(inputFrame); + + // Status bar at the very bottom + _statusLabel = new Label + { + Text = "Disconnected", + X = 0, + Y = Pos.AnchorEnd(1), + Width = Dim.Fill(), + Height = 1 + }; + _statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); + Add(_statusLabel); + + // Apply our custom color schemes to all views + ApplyColorSchemes(); + } + + /// + /// Applies the currently registered color schemes to all views. + /// Call after theme changes to refresh colors. + /// + public void ApplyColorSchemes() + { + var baseScheme = SchemeManager.GetScheme("Base"); + var menuScheme = SchemeManager.GetScheme("Menu"); + + if (baseScheme is not null) + { + this.SetScheme(baseScheme); + + // Propagate to all child views that should use the base scheme + foreach (var sub in SubViews) + { + if (sub != _menuBar && sub != _statusLabel) + sub.SetScheme(baseScheme); + } + } + + if (menuScheme is not null) + { + _menuBar.SetScheme(menuScheme); + _statusLabel.SetScheme(menuScheme); + } + } + + /// + /// Builds the menu bar with File, Server, User menus and a theme submenu. + /// + private MenuBar BuildMenuBar() + { + // Build theme menu items and prepend them with a separator header + var themeItems = new List(); + foreach (var t in Themes.ThemeManager.GetAvailableThemes()) + { + var name = t.Name; + themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty)); + } + + // Combine user items with theme items, separated by a line + var userMenuChildren = new MenuItem[] + { + new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty), + new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty), + }; + + // Merge: user items + separator + theme items + var allUserItems = new MenuItem[userMenuChildren.Length + 1 + themeItems.Count]; + userMenuChildren.CopyTo(allUserItems, 0); + allUserItems[userMenuChildren.Length] = null!; // null separator + for (int i = 0; i < themeItems.Count; i++) + allUserItems[userMenuChildren.Length + 1 + i] = themeItems[i]; + + return new MenuBar( + [ + new MenuBarItem("_File", + [ + new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty) + ]), + new MenuBarItem("_Server", + [ + new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), + new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), + null!, + new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty) + ]), + new MenuBarItem("_User", allUserItems) + ]); + } + + /// + /// Rebuilds and replaces the menu bar (e.g., after theme list changes). + /// + public void RefreshMenuBar() + { + Remove(_menuBar); + _menuBar = BuildMenuBar(); + Add(_menuBar); + ApplyColorSchemes(); + SetNeedsDraw(); + } + + private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs e) + { + var index = e.NewValue; + if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count) + { + var channelName = _channelNames[index.Value]; + if (channelName != _currentChannel) + { + SwitchToChannel(channelName); + OnChannelSelected?.Invoke(channelName); + } + } + } + + private void OnInputKeyDown(object? sender, Key e) + { + if (e == Key.Enter.WithShift) + { + // Shift+Enter: let TextView handle it (inserts newline) + return; + } + + if (e == Key.Enter) + { + var text = _inputField.Text?.Trim() ?? string.Empty; + if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel)) + { + OnMessageSubmitted?.Invoke(_currentChannel, text); + _inputField.Text = string.Empty; + } + e.Handled = true; + } + } + + /// + /// Add a message to the specified channel's message list and refresh if it is the current channel. + /// + public void AddMessage(MessageDto message) + { + var lines = FormatMessage(message); + if (!_channelMessages.TryGetValue(message.ChannelName, out var messages)) + { + messages = []; + _channelMessages[message.ChannelName] = messages; + } + + foreach (var line in lines) + { + messages.Add(line); + } + + if (message.ChannelName == _currentChannel) + { + RefreshMessages(); + } + } + + /// + /// Add a system/informational message to a channel. + /// + public void AddSystemMessage(string channelName, string text) + { + var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}"; + if (!_channelMessages.TryGetValue(channelName, out var messages)) + { + messages = []; + _channelMessages[channelName] = messages; + } + messages.Add(formatted); + + if (channelName == _currentChannel) + { + RefreshMessages(); + } + } + + /// + /// Add a status change message to a channel. + /// + public void AddStatusMessage(string channelName, string username, string status) + { + var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}"; + if (!_channelMessages.TryGetValue(channelName, out var messages)) + { + messages = []; + _channelMessages[channelName] = messages; + } + messages.Add(formatted); + + if (channelName == _currentChannel) + { + RefreshMessages(); + } + } + + /// + /// Set the list of available channels and refresh the channel list view. + /// + public void SetChannels(List channels) + { + _channelNames.Clear(); + foreach (var ch in channels) + { + _channelNames.Add(ch.Name); + if (!_channelMessages.ContainsKey(ch.Name)) + _channelMessages[ch.Name] = []; + } + _channelList.SetSource(new ObservableCollection(_channelNames)); + } + + /// + /// Show an error message to the user. + /// + public void ShowError(string message) + { + MessageBox.ErrorQuery(_app, "Error", message, "OK"); + } + + /// + /// Update the connection status displayed in the status bar. + /// + public void UpdateStatusBar(string status) + { + var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}"; + var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}"; + _statusLabel.Text = $" {status}{userPart}{channelPart}"; + } + + /// + /// Set the current user name for display in the status bar. + /// + public void SetCurrentUser(string username) + { + _currentUser = username; + } + + /// + /// Get the current channel name. + /// + public string CurrentChannel => _currentChannel; + + /// + /// Get all channel names that have message buffers (for broadcasting status changes). + /// + public IReadOnlyList GetChannelNames() => _channelNames.AsReadOnly(); + + /// + /// Switch the chat view to the given channel. + /// + public void SwitchToChannel(string channelName) + { + _currentChannel = channelName; + _chatFrame.Title = $"#{channelName}"; + RefreshMessages(); + + // Update channel list selection + var idx = _channelNames.IndexOf(channelName); + if (idx >= 0) + _channelList.SelectedItem = idx; + } + + /// + /// Load historical messages into a channel (prepend). + /// + public void LoadHistory(string channelName, List messages) + { + if (!_channelMessages.TryGetValue(channelName, out var existing)) + { + existing = []; + _channelMessages[channelName] = existing; + } + + var formatted = messages.SelectMany(FormatMessage).ToList(); + existing.InsertRange(0, formatted); + + if (channelName == _currentChannel) + { + RefreshMessages(); + } + } + + /// + /// Clear all messages and channels (used on disconnect). + /// + public void ClearAll() + { + _channelNames.Clear(); + _channelMessages.Clear(); + _currentChannel = string.Empty; + _currentUser = string.Empty; + _channelList.SetSource(new ObservableCollection(_channelNames)); + _chatFrame.Title = "Chat"; + RefreshMessages(); + } + + /// + /// Focus the input field for typing. + /// + public void FocusInput() + { + _inputField.SetFocus(); + } + + private void RefreshMessages() + { + if (_channelMessages.TryGetValue(_currentChannel, out var messages)) + { + _messageList.SetSource(new ObservableCollection(messages)); + if (messages.Count > 0) + _messageList.SelectedItem = messages.Count - 1; + } + else + { + _messageList.SetSource(new ObservableCollection(new List())); + } + } + + /// + /// Format a message DTO into one or more display lines based on its MessageType. + /// + private static List FormatMessage(MessageDto message) + { + var time = message.SentAt.ToLocalTime().ToString("HH:mm"); + var sender = message.SenderNicknameColor is not null + ? $"<{message.SenderUsername}>" + : message.SenderUsername + ":"; + + var lines = new List(); + + switch (message.Type) + { + case MessageType.Image: + lines.Add($"[{time}] {sender} [Image]"); + // Content IS the ASCII art — add each line as a separate list item + if (!string.IsNullOrWhiteSpace(message.Content)) + { + foreach (var artLine in message.Content.Split('\n')) + { + lines.Add($" {artLine}"); + } + } + break; + + case MessageType.File: + var fileName = message.AttachmentFileName ?? "unknown"; + var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; + lines.Add($"[{time}] {sender} [File: {fileName}]{fileContent}"); + break; + + case MessageType.Text: + default: + var contentLines = message.Content.Split('\n'); + lines.Add($"[{time}] {sender} {contentLines[0]}"); + // Continuation lines indented to align with first line's content + for (int i = 1; i < contentLines.Length; i++) + { + lines.Add($" {contentLines[i]}"); + } + break; + } + + return lines; + } +} diff --git a/src/EchoHub.Client/UI/ProfileEditDialog.cs b/src/EchoHub.Client/UI/ProfileEditDialog.cs new file mode 100644 index 0000000..b932df3 --- /dev/null +++ b/src/EchoHub.Client/UI/ProfileEditDialog.cs @@ -0,0 +1,190 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Drawing; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI; + +/// +/// Result returned from the profile edit dialog. +/// +public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor); + +/// +/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color). +/// +public sealed class ProfileEditDialog +{ + /// + /// Shows the profile edit dialog and returns the result, or null if cancelled. + /// + public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor) + { + ProfileEditResult? result = null; + + var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 }; + + // Display Name + var nameLabel = new Label + { + Text = "Display Name:", + X = 1, + Y = 1 + }; + var nameField = new TextField + { + Text = currentDisplayName ?? "", + X = 17, + Y = 1, + Width = Dim.Fill(2) + }; + + // Bio + var bioLabel = new Label + { + Text = "Bio:", + X = 1, + Y = 3 + }; + var bioField = new TextField + { + Text = currentBio ?? "", + X = 17, + Y = 3, + Width = Dim.Fill(2) + }; + + // Nickname Color + var colorLabel = new Label + { + Text = "Nickname Color:", + X = 1, + Y = 5 + }; + var colorField = new TextField + { + Text = currentColor ?? "", + X = 17, + Y = 5, + Width = Dim.Fill(2) + }; + + var colorHintLabel = new Label + { + Text = "(hex e.g. #FF5733)", + X = 17, + Y = 6 + }; + colorHintLabel.SetScheme(new Scheme + { + Normal = new Attribute(Color.DarkGray, Color.Blue) + }); + + // Color Preview + var previewLabel = new Label + { + Text = "Preview:", + X = 1, + Y = 8 + }; + var colorPreview = new Label + { + Text = "\u2588\u2588\u2588\u2588\u2588\u2588", + X = 17, + Y = 8 + }; + + UpdateColorPreview(colorPreview, colorField.Text); + + colorField.TextChanged += (sender, e) => + { + UpdateColorPreview(colorPreview, colorField.Text); + }; + + // Buttons + var saveButton = new Button + { + Text = "Save", + IsDefault = true, + X = Pos.Center() - 10, + Y = 10 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 5, + Y = 10 + }; + + saveButton.Accepting += (s, e) => + { + var displayName = NullIfEmpty(nameField.Text?.Trim()); + var bio = NullIfEmpty(bioField.Text?.Trim()); + var nicknameColor = NullIfEmpty(colorField.Text?.Trim()); + + result = new ProfileEditResult(displayName, bio, nicknameColor); + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField, + colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton); + + nameField.SetFocus(); + app.Run(dialog); + + return result; + } + + /// + /// Attempts to parse a hex color string and update the preview label color. + /// + private static void UpdateColorPreview(Label preview, string? hexColor) + { + if (string.IsNullOrWhiteSpace(hexColor)) + { + preview.SetScheme(new Scheme + { + Normal = new Attribute(Color.White, Color.Blue) + }); + return; + } + + var color = ParseHexToTrueColor(hexColor.Trim()); + preview.SetScheme(new Scheme + { + Normal = new Attribute(color, Color.Blue) + }); + } + + /// + /// Parses a hex color string to a Terminal.Gui TrueColor Color. + /// V2 supports TrueColor via new Color(r, g, b). + /// + private static Color ParseHexToTrueColor(string hex) + { + if (hex.StartsWith('#')) + hex = hex[1..]; + + if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb)) + return Color.White; + + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + return new Color(r, g, b); + } + + private static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/EchoHub.Client/UI/StatusDialog.cs b/src/EchoHub.Client/UI/StatusDialog.cs new file mode 100644 index 0000000..acd1653 --- /dev/null +++ b/src/EchoHub.Client/UI/StatusDialog.cs @@ -0,0 +1,96 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; +using EchoHub.Core.Models; + +namespace EchoHub.Client.UI; + +/// +/// Result returned from the status dialog. +/// +public record StatusDialogResult(UserStatus Status, string? StatusMessage); + +/// +/// A Terminal.Gui dialog for setting the user's status and status message. +/// +public sealed class StatusDialog +{ + /// + /// Shows the status dialog and returns the result, or null if cancelled. + /// + public static StatusDialogResult? Show(IApplication app, UserStatus currentStatus, string? currentMessage) + { + StatusDialogResult? result = null; + + var dialog = new Dialog { Title = "Set Status", Width = 50, Height = 12 }; + + var statusLabel = new Label + { + Text = "Status:", + X = 1, + Y = 1 + }; + + var optionSelector = new OptionSelector + { + X = 12, + Y = 1 + }; + optionSelector.Value = currentStatus; + + var messageLabel = new Label + { + Text = "Message:", + X = 1, + Y = 6 + }; + var messageField = new TextField + { + Text = currentMessage ?? "", + X = 12, + Y = 6, + Width = Dim.Fill(2) + }; + + var saveButton = new Button + { + Text = "Save", + IsDefault = true, + X = Pos.Center() - 10, + Y = 8 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 5, + Y = 8 + }; + + saveButton.Accepting += (s, e) => + { + var status = optionSelector.Value ?? UserStatus.Online; + var message = messageField.Text?.Trim(); + if (string.IsNullOrWhiteSpace(message)) + message = null; + + result = new StatusDialogResult(status, message); + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(statusLabel, optionSelector, messageLabel, messageField, saveButton, cancelButton); + + optionSelector.SetFocus(); + app.Run(dialog); + + return result; + } +} diff --git a/src/EchoHub.Client/UI/UserPanelDialog.cs b/src/EchoHub.Client/UI/UserPanelDialog.cs new file mode 100644 index 0000000..0291063 --- /dev/null +++ b/src/EchoHub.Client/UI/UserPanelDialog.cs @@ -0,0 +1,336 @@ +using System.Collections.ObjectModel; +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Drawing; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Client.Config; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI; + +/// +/// Action selected by the user in the user panel dialog. +/// +public enum UserPanelAction +{ + Close, + EditProfile, + SetStatus +} + +/// +/// A Terminal.Gui dialog for viewing the user panel -- profile info, saved servers, and status. +/// +public sealed class UserPanelDialog +{ + /// + /// Shows the user panel dialog and returns the action the user selected. + /// + public static UserPanelAction Show( + IApplication app, + UserProfileDto? profile, + List savedServers, + UserStatus currentStatus, + string? currentStatusMessage) + { + var action = UserPanelAction.Close; + + var dialog = new Dialog { Title = "User Panel", Width = 70, Height = 24 }; + + // -- Left side: Profile info ------------------------------------------ + var profileFrame = new FrameView + { + Title = "Profile", + X = 0, + Y = 0, + Width = 35, + Height = Dim.Fill(3) + }; + + int row = 0; + + // Username + var usernameLabel = new Label + { + Text = "Username:", + X = 1, + Y = row + }; + var usernameValue = new Label + { + Text = profile?.Username ?? "N/A", + X = 12, + Y = row + }; + usernameValue.SetScheme(new Scheme + { + Normal = new Attribute(Color.BrightYellow, Color.Blue) + }); + profileFrame.Add(usernameLabel, usernameValue); + row += 1; + + // Display Name + var displayLabel = new Label + { + Text = "Name:", + X = 1, + Y = row + }; + var displayValue = new Label + { + Text = profile?.DisplayName ?? "-", + X = 12, + Y = row + }; + profileFrame.Add(displayLabel, displayValue); + row += 1; + + // Status + var statusLabel = new Label + { + Text = "Status:", + X = 1, + Y = row + }; + var statusText = FormatStatus(currentStatus); + var statusValue = new Label + { + Text = statusText, + X = 12, + Y = row + }; + statusValue.SetScheme(new Scheme + { + Normal = new Attribute(GetStatusColor(currentStatus), Color.Blue) + }); + profileFrame.Add(statusLabel, statusValue); + row += 1; + + // Status Message + if (!string.IsNullOrWhiteSpace(currentStatusMessage)) + { + var msgLabel = new Label + { + Text = "Message:", + X = 1, + Y = row + }; + var msgValue = new Label + { + Text = Truncate(currentStatusMessage, 20), + X = 12, + Y = row + }; + profileFrame.Add(msgLabel, msgValue); + row += 1; + } + + // Bio + row += 1; + var bioLabel = new Label + { + Text = "Bio:", + X = 1, + Y = row + }; + profileFrame.Add(bioLabel); + row += 1; + + var bioText = profile?.Bio ?? "-"; + var bioView = new TextView() + { + X = 1, + Y = row, + Width = Dim.Fill(1), + Height = 3, + Text = bioText, + ReadOnly = true + }; + bioView.SetScheme(new Scheme + { + Normal = new Attribute(Color.White, Color.DarkGray), + Focus = new Attribute(Color.White, Color.DarkGray) + }); + profileFrame.Add(bioView); + row += 3; + + // Color + var colorLabel = new Label + { + Text = "Color:", + X = 1, + Y = row + }; + var colorValue = new Label + { + Text = profile?.NicknameColor ?? "-", + X = 12, + Y = row + }; + profileFrame.Add(colorLabel, colorValue); + row += 1; + + // ASCII Avatar + if (!string.IsNullOrWhiteSpace(profile?.AvatarAscii)) + { + row += 1; + var avatarFrame = new FrameView + { + Title = "Avatar", + X = 1, + Y = row, + Width = Dim.Fill(1), + Height = 4 + }; + var avatarLabel = new Label + { + Text = profile.AvatarAscii, + X = 0, + Y = 0 + }; + avatarFrame.Add(avatarLabel); + profileFrame.Add(avatarFrame); + } + + dialog.Add(profileFrame); + + // -- Right side: Saved Servers ---------------------------------------- + var serversFrame = new FrameView + { + Title = "Saved Servers", + X = 36, + Y = 0, + Width = Dim.Fill(1), + Height = Dim.Fill(3) + }; + + var serverNames = savedServers.Select(s => s.Name).ToList(); + var serverList = new ListView + { + Source = new ListWrapper(new ObservableCollection(serverNames)), + X = 0, + Y = 0, + Width = Dim.Fill(0), + Height = Dim.Fill(4) + }; + + var serverUrlLabel = new Label + { + Text = "URL: -", + X = 0, + Y = Pos.AnchorEnd(3), + Width = Dim.Fill(0) + }; + var serverLastLabel = new Label + { + Text = "Last: -", + X = 0, + Y = Pos.AnchorEnd(2), + Width = Dim.Fill(0) + }; + var serverUserLabel = new Label + { + Text = "User: -", + X = 0, + Y = Pos.AnchorEnd(1), + Width = Dim.Fill(0) + }; + + serverList.ValueChanged += (sender, e) => + { + var index = e.NewValue; + if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) + { + var server = savedServers[index.Value]; + serverUrlLabel.Text = $"URL: {Truncate(server.Url, 25)}"; + serverLastLabel.Text = $"Last: {server.LastConnected:yyyy-MM-dd HH:mm}"; + serverUserLabel.Text = $"User: {server.Username ?? "-"}"; + } + }; + + // Show initial details if there are servers + if (savedServers.Count > 0) + { + var first = savedServers[0]; + serverUrlLabel.Text = $"URL: {Truncate(first.Url, 25)}"; + serverLastLabel.Text = $"Last: {first.LastConnected:yyyy-MM-dd HH:mm}"; + serverUserLabel.Text = $"User: {first.Username ?? "-"}"; + } + + serversFrame.Add(serverList, serverUrlLabel, serverLastLabel, serverUserLabel); + dialog.Add(serversFrame); + + // -- Bottom buttons --------------------------------------------------- + var editProfileButton = new Button + { + Text = "Edit Profile", + X = Pos.Center() - 22, + Y = Pos.AnchorEnd(2) + }; + + var setStatusButton = new Button + { + Text = "Set Status", + X = Pos.Center() - 5, + Y = Pos.AnchorEnd(2) + }; + + var closeButton = new Button + { + Text = "Close", + IsDefault = true, + X = Pos.Center() + 12, + Y = Pos.AnchorEnd(2) + }; + + editProfileButton.Accepting += (s, e) => + { + action = UserPanelAction.EditProfile; + e.Handled = true; + app.RequestStop(); + }; + + setStatusButton.Accepting += (s, e) => + { + action = UserPanelAction.SetStatus; + e.Handled = true; + app.RequestStop(); + }; + + closeButton.Accepting += (s, e) => + { + action = UserPanelAction.Close; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(editProfileButton, setStatusButton, closeButton); + + app.Run(dialog); + + return action; + } + + private static string FormatStatus(UserStatus status) => status switch + { + UserStatus.Online => "\u25cf Online", + UserStatus.Away => "\u25cf Away", + UserStatus.DoNotDisturb => "\u25cf Do Not Disturb", + UserStatus.Invisible => "\u25cb Invisible", + _ => "\u25cf Unknown" + }; + + private static Color GetStatusColor(UserStatus status) => status switch + { + UserStatus.Online => Color.BrightGreen, + UserStatus.Away => Color.BrightYellow, + UserStatus.DoNotDisturb => Color.BrightRed, + UserStatus.Invisible => Color.Gray, + _ => Color.White + }; + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength - 3), "..."); +} diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs new file mode 100644 index 0000000..6efeba0 --- /dev/null +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -0,0 +1,13 @@ +namespace EchoHub.Core.Constants; + +public static class HubConstants +{ + public const string ChatHubPath = "/hubs/chat"; + public const string DefaultChannel = "general"; + public const int DefaultHistoryCount = 50; + public const int MaxMessageLength = 2000; + public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB + public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB + public const int AsciiArtWidth = 80; + public const int AsciiArtHeight = 40; +} diff --git a/src/EchoHub.Core/Contracts/IEchoHubClient.cs b/src/EchoHub.Core/Contracts/IEchoHubClient.cs new file mode 100644 index 0000000..fc33e43 --- /dev/null +++ b/src/EchoHub.Core/Contracts/IEchoHubClient.cs @@ -0,0 +1,16 @@ +using EchoHub.Core.DTOs; + +namespace EchoHub.Core.Contracts; + +/// +/// Methods the server can invoke on connected clients. +/// +public interface IEchoHubClient +{ + Task ReceiveMessage(MessageDto message); + Task UserJoined(string channelName, string username); + Task UserLeft(string channelName, string username); + Task ChannelUpdated(ChannelDto channel); + Task UserStatusChanged(UserPresenceDto presence); + Task Error(string message); +} diff --git a/src/EchoHub.Core/DTOs/AuthDtos.cs b/src/EchoHub.Core/DTOs/AuthDtos.cs new file mode 100644 index 0000000..30437eb --- /dev/null +++ b/src/EchoHub.Core/DTOs/AuthDtos.cs @@ -0,0 +1,7 @@ +namespace EchoHub.Core.DTOs; + +public record RegisterRequest(string Username, string Password, string? DisplayName = null); + +public record LoginRequest(string Username, string Password); + +public record LoginResponse(string Token, string Username, string? DisplayName, string? NicknameColor); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs new file mode 100644 index 0000000..afe8ae6 --- /dev/null +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -0,0 +1,31 @@ +using EchoHub.Core.Models; + +namespace EchoHub.Core.DTOs; + +public record MessageDto( + Guid Id, + string Content, + string SenderUsername, + string? SenderNicknameColor, + string ChannelName, + MessageType Type, + string? AttachmentUrl, + string? AttachmentFileName, + DateTimeOffset SentAt); + +public record ChannelDto( + Guid Id, + string Name, + string? Topic, + int MessageCount, + DateTimeOffset CreatedAt); + +public record UserDto( + Guid Id, + string Username, + string? DisplayName, + string? NicknameColor, + UserStatus Status, + DateTimeOffset LastSeenAt); + +public record SendMessageRequest(string ChannelName, string Content); diff --git a/src/EchoHub.Core/DTOs/ProfileDtos.cs b/src/EchoHub.Core/DTOs/ProfileDtos.cs new file mode 100644 index 0000000..5ad9312 --- /dev/null +++ b/src/EchoHub.Core/DTOs/ProfileDtos.cs @@ -0,0 +1,31 @@ +using EchoHub.Core.Models; + +namespace EchoHub.Core.DTOs; + +public record UserProfileDto( + Guid Id, + string Username, + string? DisplayName, + string? Bio, + string? NicknameColor, + string? AvatarAscii, + UserStatus Status, + string? StatusMessage, + DateTimeOffset CreatedAt, + DateTimeOffset LastSeenAt); + +public record UpdateProfileRequest( + string? DisplayName = null, + string? Bio = null, + string? NicknameColor = null); + +public record UpdateStatusRequest( + UserStatus Status, + string? StatusMessage = null); + +public record UserPresenceDto( + string Username, + string? DisplayName, + string? NicknameColor, + UserStatus Status, + string? StatusMessage); diff --git a/src/EchoHub.Core/DTOs/ServerDtos.cs b/src/EchoHub.Core/DTOs/ServerDtos.cs new file mode 100644 index 0000000..a80d922 --- /dev/null +++ b/src/EchoHub.Core/DTOs/ServerDtos.cs @@ -0,0 +1,15 @@ +namespace EchoHub.Core.DTOs; + +public record ServerInfoDto( + Guid Id, + string Name, + string? Description, + string Url, + int OnlineUsers, + int TotalChannels, + bool IsOnline, + DateTimeOffset LastPingAt); + +public record RegisterServerRequest(string Name, string Url, string? Description = null); + +public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels); diff --git a/src/EchoHub.Core/EchoHub.Core.csproj b/src/EchoHub.Core/EchoHub.Core.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/src/EchoHub.Core/EchoHub.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs new file mode 100644 index 0000000..dca533c --- /dev/null +++ b/src/EchoHub.Core/Models/Channel.cs @@ -0,0 +1,12 @@ +namespace EchoHub.Core.Models; + +public class Channel +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public string? Topic { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public Guid CreatedByUserId { get; set; } + + public List Messages { get; set; } = []; +} diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs new file mode 100644 index 0000000..7c35de3 --- /dev/null +++ b/src/EchoHub.Core/Models/Message.cs @@ -0,0 +1,17 @@ +namespace EchoHub.Core.Models; + +public class Message +{ + public Guid Id { get; set; } + public required string Content { get; set; } + public MessageType Type { get; set; } = MessageType.Text; + public string? AttachmentUrl { get; set; } + public string? AttachmentFileName { get; set; } + public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; + + public Guid ChannelId { get; set; } + public Channel? Channel { get; set; } + + public Guid SenderUserId { get; set; } + public required string SenderUsername { get; set; } +} diff --git a/src/EchoHub.Core/Models/MessageType.cs b/src/EchoHub.Core/Models/MessageType.cs new file mode 100644 index 0000000..6957785 --- /dev/null +++ b/src/EchoHub.Core/Models/MessageType.cs @@ -0,0 +1,8 @@ +namespace EchoHub.Core.Models; + +public enum MessageType +{ + Text, + Image, + File +} diff --git a/src/EchoHub.Core/Models/ServerInfo.cs b/src/EchoHub.Core/Models/ServerInfo.cs new file mode 100644 index 0000000..1e97024 --- /dev/null +++ b/src/EchoHub.Core/Models/ServerInfo.cs @@ -0,0 +1,14 @@ +namespace EchoHub.Core.Models; + +public class ServerInfo +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public string? Description { get; set; } + public required string Url { get; set; } + public int OnlineUsers { get; set; } + public int TotalChannels { get; set; } + public DateTimeOffset RegisteredAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastPingAt { get; set; } = DateTimeOffset.UtcNow; + public bool IsOnline { get; set; } = true; +} diff --git a/src/EchoHub.Core/Models/User.cs b/src/EchoHub.Core/Models/User.cs new file mode 100644 index 0000000..4df2479 --- /dev/null +++ b/src/EchoHub.Core/Models/User.cs @@ -0,0 +1,16 @@ +namespace EchoHub.Core.Models; + +public class User +{ + public Guid Id { get; set; } + public required string Username { get; set; } + public required string PasswordHash { get; set; } + public string? DisplayName { get; set; } + public string? Bio { get; set; } + public string? NicknameColor { get; set; } + public string? AvatarAscii { get; set; } + public UserStatus Status { get; set; } = UserStatus.Online; + public string? StatusMessage { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/EchoHub.Core/Models/UserStatus.cs b/src/EchoHub.Core/Models/UserStatus.cs new file mode 100644 index 0000000..2ddea72 --- /dev/null +++ b/src/EchoHub.Core/Models/UserStatus.cs @@ -0,0 +1,9 @@ +namespace EchoHub.Core.Models; + +public enum UserStatus +{ + Online, + Away, + DoNotDisturb, + Invisible +} diff --git a/src/EchoHub.Server/Auth/JwtTokenService.cs b/src/EchoHub.Server/Auth/JwtTokenService.cs new file mode 100644 index 0000000..6cfb8b3 --- /dev/null +++ b/src/EchoHub.Server/Auth/JwtTokenService.cs @@ -0,0 +1,40 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using EchoHub.Core.Models; +using Microsoft.IdentityModel.Tokens; + +namespace EchoHub.Server.Auth; + +public class JwtTokenService(IConfiguration configuration) +{ + private readonly string _secret = configuration["Jwt:Secret"] + ?? throw new InvalidOperationException("Jwt:Secret is not configured."); + private readonly string _issuer = configuration["Jwt:Issuer"] + ?? throw new InvalidOperationException("Jwt:Issuer is not configured."); + private readonly string _audience = configuration["Jwt:Audience"] + ?? throw new InvalidOperationException("Jwt:Audience is not configured."); + + public string GenerateToken(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + Claim[] claims = + [ + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new("username", user.Username), + new("display_name", user.DisplayName ?? user.Username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + ]; + + var token = new JwtSecurityToken( + issuer: _issuer, + audience: _audience, + claims: claims, + expires: DateTime.UtcNow.AddDays(7), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs new file mode 100644 index 0000000..fcad6ba --- /dev/null +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -0,0 +1,76 @@ +using EchoHub.Core.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace EchoHub.Server.Data; + +public class EchoHubDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet Channels => Set(); + public DbSet Messages => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + optionsBuilder.UseSqlite("Data Source=echohub.db"); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(u => u.Id); + entity.HasIndex(u => u.Username).IsUnique(); + entity.Property(u => u.Username).IsRequired().HasMaxLength(50); + entity.Property(u => u.PasswordHash).IsRequired(); + entity.Property(u => u.DisplayName).HasMaxLength(100); + entity.Property(u => u.Bio).HasMaxLength(500); + entity.Property(u => u.NicknameColor).HasMaxLength(7); + entity.Property(u => u.AvatarAscii).HasMaxLength(10000); + entity.Property(u => u.StatusMessage).HasMaxLength(100); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(c => c.Id); + entity.HasIndex(c => c.Name); + entity.Property(c => c.Name).IsRequired().HasMaxLength(100); + entity.Property(c => c.Topic).HasMaxLength(500); + + entity.HasMany(c => c.Messages) + .WithOne(m => m.Channel) + .HasForeignKey(m => m.ChannelId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(m => m.Id); + entity.HasIndex(m => m.SentAt); + entity.Property(m => m.Content).IsRequired().HasMaxLength(2000); + entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50); + entity.Property(m => m.AttachmentUrl).HasMaxLength(500); + entity.Property(m => m.AttachmentFileName).HasMaxLength(255); + }); + + // SQLite does not support DateTimeOffset in ORDER BY clauses. + // Convert all DateTimeOffset properties to Unix milliseconds (long) for storage. + var dateTimeOffsetConverter = new ValueConverter( + v => v.ToUnixTimeMilliseconds(), + v => DateTimeOffset.FromUnixTimeMilliseconds(v)); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == typeof(DateTimeOffset) || property.ClrType == typeof(DateTimeOffset?)) + { + property.SetValueConverter(dateTimeOffsetConverter); + } + } + } + } +} diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj new file mode 100644 index 0000000..e64b4a2 --- /dev/null +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs new file mode 100644 index 0000000..2e3694b --- /dev/null +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -0,0 +1,263 @@ +using System.Security.Claims; +using EchoHub.Core.Constants; +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.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Hubs; + +[Authorize] +public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTracker presenceTracker) : Hub +{ + private Guid CurrentUserId => + Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new HubException("User ID claim not found.")); + + private string CurrentUsername => + Context.User?.FindFirstValue("username") + ?? throw new HubException("Username claim not found."); + + public override async Task OnConnectedAsync() + { + presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername); + + var user = await db.Users.FindAsync(CurrentUserId); + if (user is not null) + { + user.LastSeenAt = DateTimeOffset.UtcNow; + user.Status = UserStatus.Online; + await db.SaveChangesAsync(); + } + + await base.OnConnectedAsync(); + + logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + // Capture channels before disconnecting, since UserDisconnected clears them + // when the last connection for a user is removed. + var preDisconnectUsername = Context.User?.FindFirstValue("username"); + var channelsBeforeDisconnect = preDisconnectUsername is not null + ? presenceTracker.GetChannelsForUser(preDisconnectUsername) + : []; + + var username = presenceTracker.UserDisconnected(Context.ConnectionId); + + if (username is not null && !presenceTracker.IsOnline(username)) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is not null) + { + user.LastSeenAt = DateTimeOffset.UtcNow; + user.Status = UserStatus.Invisible; + await db.SaveChangesAsync(); + + var presence = new UserPresenceDto( + username, + user.DisplayName, + user.NicknameColor, + UserStatus.Invisible, + user.StatusMessage); + + foreach (var channel in channelsBeforeDisconnect) + { + await Clients.Group(channel).UserStatusChanged(presence); + } + } + } + + await base.OnDisconnectedAsync(exception); + + logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId); + } + + public async Task> JoinChannel(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (channel is null) + { + channel = new Channel + { + Id = Guid.NewGuid(), + Name = channelName, + CreatedByUserId = CurrentUserId, + }; + + db.Channels.Add(channel); + await db.SaveChangesAsync(); + + logger.LogInformation("Channel '{Channel}' created by {User}", channelName, CurrentUsername); + } + + presenceTracker.JoinChannel(CurrentUsername, channelName); + + await Groups.AddToGroupAsync(Context.ConnectionId, channelName); + await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername); + + logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName); + + var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount); + return history; + } + + public async Task LeaveChannel(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + presenceTracker.LeaveChannel(CurrentUsername, channelName); + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName); + await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername); + + logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName); + } + + public async Task SendMessage(string channelName, string content) + { + channelName = channelName.ToLowerInvariant().Trim(); + + if (string.IsNullOrWhiteSpace(content)) + { + await Clients.Caller.Error("Message content cannot be empty."); + return; + } + + if (content.Length > HubConstants.MaxMessageLength) + { + await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."); + return; + } + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (channel is null) + { + await Clients.Caller.Error($"Channel '{channelName}' does not exist."); + return; + } + + var sender = await db.Users.FindAsync(CurrentUserId); + + var message = new Message + { + Id = Guid.NewGuid(), + Content = content, + Type = MessageType.Text, + SentAt = DateTimeOffset.UtcNow, + ChannelId = channel.Id, + SenderUserId = CurrentUserId, + SenderUsername = CurrentUsername, + }; + + db.Messages.Add(message); + await db.SaveChangesAsync(); + + var messageDto = new MessageDto( + message.Id, + message.Content, + message.SenderUsername, + sender?.NicknameColor, + channelName, + MessageType.Text, + null, + null, + message.SentAt); + + await Clients.Group(channelName).ReceiveMessage(messageDto); + + logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName); + } + + public async Task> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) + { + channelName = channelName.ToLowerInvariant().Trim(); + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (channel is null) + { + return []; + } + + var messages = await db.Messages + .Where(m => m.ChannelId == channel.Id) + .OrderByDescending(m => m.SentAt) + .Take(count) + .Join(db.Users, + m => m.SenderUserId, + u => u.Id, + (m, u) => new MessageDto( + m.Id, + m.Content, + m.SenderUsername, + u.NicknameColor, + channelName, + m.Type, + m.AttachmentUrl, + m.AttachmentFileName, + m.SentAt)) + .ToListAsync(); + + messages.Reverse(); + return messages; + } + + public async Task UpdateStatus(UserStatus status, string? statusMessage) + { + var user = await db.Users.FindAsync(CurrentUserId); + + if (user is null) + { + await Clients.Caller.Error("User not found."); + return; + } + + user.Status = status; + user.StatusMessage = statusMessage; + user.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + var presence = new UserPresenceDto( + user.Username, + user.DisplayName, + user.NicknameColor, + status, + statusMessage); + + var channels = presenceTracker.GetChannelsForUser(CurrentUsername); + + foreach (var channel in channels) + { + await Clients.Group(channel).UserStatusChanged(presence); + } + } + + public async Task> GetOnlineUsers(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); + + var users = await db.Users + .Where(u => onlineUsernames.Contains(u.Username)) + .Select(u => new UserPresenceDto( + u.Username, + u.DisplayName, + u.NicknameColor, + u.Status, + u.StatusMessage)) + .ToListAsync(); + + return users; + } +} diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs new file mode 100644 index 0000000..90ca6c3 --- /dev/null +++ b/src/EchoHub.Server/Program.cs @@ -0,0 +1,399 @@ +using System.Security.Claims; +using System.Text; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using EchoHub.Server.Auth; +using EchoHub.Server.Data; +using EchoHub.Server.Hubs; +using EchoHub.Server.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// ── SQLite + EF Core ────────────────────────────────────────────────────────── +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=echohub.db"; + +builder.Services.AddDbContext(options => + options.UseSqlite(connectionString)); + +// ── JWT Authentication ──────────────────────────────────────────────────────── +var jwtSecret = builder.Configuration["Jwt:Secret"] + ?? throw new InvalidOperationException("Jwt:Secret must be configured."); +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "EchoHub.Server"; +var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "EchoHub.Client"; + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), + }; + + // Allow SignalR clients to send the JWT via query string + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments(HubConstants.ChatHubPath)) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + }; +}); + +builder.Services.AddAuthorization(); + +// ── SignalR ─────────────────────────────────────────────────────────────────── +builder.Services.AddSignalR(); + +// ── Services ────────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── CORS (allow all for development) ────────────────────────────────────────── +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + .SetIsOriginAllowed(_ => true); + }); +}); + +var app = builder.Build(); + +// ── Database initialization ─────────────────────────────────────────────────── +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + + if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) + { + db.Channels.Add(new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }); + + await db.SaveChangesAsync(); + } +} + +// ── Middleware ───────────────────────────────────────────────────────────────── +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); + +// ── Auth Endpoints ──────────────────────────────────────────────────────────── +var auth = app.MapGroup("/api/auth"); + +auth.MapPost("/register", async (RegisterRequest request, EchoHubDbContext db, JwtTokenService jwt) => +{ + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + { + return Results.BadRequest(new { Error = "Username and password are required." }); + } + + if (request.Username.Length < 3 || request.Username.Length > 50) + { + return Results.BadRequest(new { Error = "Username must be between 3 and 50 characters." }); + } + + if (request.Password.Length < 6) + { + return Results.BadRequest(new { Error = "Password must be at least 6 characters." }); + } + + var normalizedUsername = request.Username.ToLowerInvariant().Trim(); + + if (await db.Users.AnyAsync(u => u.Username == normalizedUsername)) + { + return Results.Conflict(new { Error = "Username is already taken." }); + } + + var user = new User + { + Id = Guid.NewGuid(), + Username = normalizedUsername, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + DisplayName = request.DisplayName?.Trim(), + }; + + db.Users.Add(user); + await db.SaveChangesAsync(); + + var token = jwt.GenerateToken(user); + + return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); +}); + +auth.MapPost("/login", async (LoginRequest request, EchoHubDbContext db, JwtTokenService jwt) => +{ + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + { + return Results.BadRequest(new { Error = "Username and password are required." }); + } + + var normalizedUsername = request.Username.ToLowerInvariant().Trim(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + + if (user is null || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + { + return Results.Unauthorized(); + } + + user.LastSeenAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + var token = jwt.GenerateToken(user); + + return Results.Ok(new LoginResponse(token, user.Username, user.DisplayName, user.NicknameColor)); +}); + +// ── Channel Endpoints ───────────────────────────────────────────────────────── +app.MapGet("/api/channels", async (EchoHubDbContext db) => +{ + var channels = await db.Channels + .Select(c => new ChannelDto( + c.Id, + c.Name, + c.Topic, + c.Messages.Count, + c.CreatedAt)) + .ToListAsync(); + + return Results.Ok(channels); +}) +.RequireAuthorization(); + +// ── Server Info Endpoint ────────────────────────────────────────────────────── +app.MapGet("/api/server/info", async (EchoHubDbContext db, IConfiguration config) => +{ + var userCount = await db.Users.CountAsync(); + var channelCount = await db.Channels.CountAsync(); + + var status = new ServerStatusDto( + config["Server:Name"] ?? "EchoHub Server", + config["Server:Description"], + userCount, + channelCount); + + return Results.Ok(status); +}); + +// ── User Profile Endpoints ──────────────────────────────────────────────────── +app.MapGet("/api/users/{username}/profile", async (string username, EchoHubDbContext db) => +{ + var normalizedUsername = username.ToLowerInvariant().Trim(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == normalizedUsername); + + if (user is null) + { + return Results.NotFound(new { Error = "User not found." }); + } + + var profile = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Bio, + user.NicknameColor, + user.AvatarAscii, + user.Status, + user.StatusMessage, + user.CreatedAt, + user.LastSeenAt); + + return Results.Ok(profile); +}); + +app.MapPut("/api/users/profile", async (UpdateProfileRequest request, EchoHubDbContext db, HttpContext ctx) => +{ + var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Results.Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var user = await db.Users.FindAsync(userId); + + if (user is null) + return Results.NotFound(new { Error = "User not found." }); + + if (request.DisplayName is not null) + user.DisplayName = request.DisplayName.Trim(); + + if (request.Bio is not null) + user.Bio = request.Bio.Trim(); + + if (request.NicknameColor is not null) + user.NicknameColor = request.NicknameColor.Trim(); + + await db.SaveChangesAsync(); + + var profile = new UserProfileDto( + user.Id, + user.Username, + user.DisplayName, + user.Bio, + user.NicknameColor, + user.AvatarAscii, + user.Status, + user.StatusMessage, + user.CreatedAt, + user.LastSeenAt); + + return Results.Ok(profile); +}) +.RequireAuthorization(); + +app.MapPost("/api/users/avatar", async (HttpRequest req, EchoHubDbContext db, ImageToAsciiService asciiService, HttpContext ctx) => +{ + var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return Results.Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var user = await db.Users.FindAsync(userId); + + if (user is null) + return Results.NotFound(new { Error = "User not found." }); + + if (!req.HasFormContentType || req.Form.Files.Count == 0) + return Results.BadRequest(new { Error = "No file uploaded." }); + + var file = req.Form.Files[0]; + + if (file.Length > HubConstants.MaxAvatarSizeBytes) + return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB." }); + + using var stream = file.OpenReadStream(); + var asciiArt = asciiService.ConvertToAscii(stream); + + user.AvatarAscii = asciiArt; + await db.SaveChangesAsync(); + + return Results.Ok(new { AvatarAscii = asciiArt }); +}) +.RequireAuthorization(); + +// ── File Upload Endpoints ──────────────────────────────────────────────────── +app.MapPost("/api/channels/{channel}/upload", async (string channel, HttpRequest req, EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, HttpContext ctx) => +{ + var userIdClaim = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + var usernameClaim = ctx.User.FindFirstValue("username"); + if (userIdClaim is null || usernameClaim is null) + return Results.Unauthorized(); + + var userId = Guid.Parse(userIdClaim); + var channelName = channel.ToLowerInvariant().Trim(); + + var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + if (dbChannel is null) + return Results.NotFound(new { Error = $"Channel '{channelName}' does not exist." }); + + if (!req.HasFormContentType || req.Form.Files.Count == 0) + return Results.BadRequest(new { Error = "No file uploaded." }); + + var file = req.Form.Files[0]; + + if (file.Length > HubConstants.MaxFileSizeBytes) + return Results.BadRequest(new { Error = $"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB." }); + + using var stream = file.OpenReadStream(); + var (fileId, filePath) = await fileStorage.SaveFileAsync(stream, file.FileName); + + var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" }; + var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); + var isImage = imageExtensions.Contains(extension); + + var messageType = isImage ? MessageType.Image : MessageType.File; + var content = isImage ? asciiService.ConvertToAscii(File.OpenRead(filePath)) : file.FileName; + var attachmentUrl = $"/api/files/{fileId}"; + + var sender = await db.Users.FindAsync(userId); + + var message = new Message + { + Id = Guid.NewGuid(), + Content = content, + Type = messageType, + AttachmentUrl = attachmentUrl, + AttachmentFileName = file.FileName, + SentAt = DateTimeOffset.UtcNow, + ChannelId = dbChannel.Id, + SenderUserId = userId, + SenderUsername = usernameClaim, + }; + + db.Messages.Add(message); + await db.SaveChangesAsync(); + + var messageDto = new MessageDto( + message.Id, + message.Content, + message.SenderUsername, + sender?.NicknameColor, + channelName, + messageType, + attachmentUrl, + file.FileName, + message.SentAt); + + return Results.Ok(messageDto); +}) +.RequireAuthorization(); + +app.MapGet("/api/files/{fileId}", (string fileId, FileStorageService fileStorage) => +{ + var filePath = fileStorage.GetFilePath(fileId); + + if (filePath is null) + return Results.NotFound(new { Error = "File not found." }); + + var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + ".txt" => "text/plain", + _ => "application/octet-stream" + }; + + var fileName = Path.GetFileName(filePath); + return Results.File(filePath, contentType, fileName); +}); + +// ── SignalR Hub ─────────────────────────────────────────────────────────────── +app.MapHub(HubConstants.ChatHubPath); + +app.Run(); diff --git a/src/EchoHub.Server/Properties/launchSettings.json b/src/EchoHub.Server/Properties/launchSettings.json new file mode 100644 index 0000000..6495604 --- /dev/null +++ b/src/EchoHub.Server/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7171;http://localhost:5189", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/EchoHub.Server/Services/FileStorageService.cs b/src/EchoHub.Server/Services/FileStorageService.cs new file mode 100644 index 0000000..7eb7850 --- /dev/null +++ b/src/EchoHub.Server/Services/FileStorageService.cs @@ -0,0 +1,46 @@ +namespace EchoHub.Server.Services; + +public class FileStorageService +{ + private readonly string _storagePath; + + public FileStorageService(IConfiguration configuration) + { + _storagePath = configuration["Storage:Path"] ?? "./uploads"; + + if (!Directory.Exists(_storagePath)) + { + Directory.CreateDirectory(_storagePath); + } + } + + public async Task<(string fileId, string filePath)> SaveFileAsync(Stream stream, string fileName) + { + var fileId = Guid.NewGuid().ToString(); + var extension = Path.GetExtension(fileName); + var storedFileName = $"{fileId}{extension}"; + var filePath = Path.Combine(_storagePath, storedFileName); + + using var fileStream = File.Create(filePath); + await stream.CopyToAsync(fileStream); + + return (fileId, filePath); + } + + public string? GetFilePath(string fileId) + { + var files = Directory.GetFiles(_storagePath, $"{fileId}.*"); + + return files.Length > 0 ? files[0] : null; + } + + public void DeleteFile(string fileId) + { + var filePath = GetFilePath(fileId); + + if (filePath is not null && File.Exists(filePath)) + { + File.Delete(filePath); + } + } +} diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs new file mode 100644 index 0000000..7004fbe --- /dev/null +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -0,0 +1,41 @@ +using System.Text; +using EchoHub.Core.Constants; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace EchoHub.Server.Services; + +public class ImageToAsciiService +{ + private static readonly char[] AsciiChars = " .:-=+*#%@".ToCharArray(); + + public string ConvertToAscii(Stream imageStream, int width = HubConstants.AsciiArtWidth, int height = HubConstants.AsciiArtHeight) + { + using var image = Image.Load(imageStream); + + image.Mutate(x => x.Resize(width, height)); + + var sb = new StringBuilder(); + + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + var pixel = image[x, y]; + var brightness = 0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B; + + // Map brightness (0-255) to ASCII char index (inverted: dark pixels get dense chars) + var index = (int)((brightness / 255.0) * (AsciiChars.Length - 1)); + sb.Append(AsciiChars[index]); + } + + if (y < image.Height - 1) + { + sb.AppendLine(); + } + } + + return sb.ToString(); + } +} diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs new file mode 100644 index 0000000..7cefde3 --- /dev/null +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -0,0 +1,113 @@ +using System.Collections.Concurrent; + +namespace EchoHub.Server.Services; + +public class PresenceTracker +{ + private readonly ConcurrentDictionary _connections = new(); + private readonly ConcurrentDictionary> _userConnections = new(); + private readonly ConcurrentDictionary> _userChannels = new(); + + private readonly object _lock = new(); + + public void UserConnected(string connectionId, Guid userId, string username) + { + _connections[connectionId] = (userId, username); + + lock (_lock) + { + if (!_userConnections.TryGetValue(username, out var connections)) + { + connections = new HashSet(); + _userConnections[username] = connections; + } + + connections.Add(connectionId); + } + } + + public string? UserDisconnected(string connectionId) + { + if (!_connections.TryRemove(connectionId, out var userInfo)) + return null; + + var username = userInfo.username; + + lock (_lock) + { + if (_userConnections.TryGetValue(username, out var connections)) + { + connections.Remove(connectionId); + + if (connections.Count == 0) + { + _userConnections.TryRemove(username, out _); + _userChannels.TryRemove(username, out _); + } + } + } + + return username; + } + + public void JoinChannel(string username, string channelName) + { + lock (_lock) + { + if (!_userChannels.TryGetValue(username, out var channels)) + { + channels = new HashSet(); + _userChannels[username] = channels; + } + + channels.Add(channelName); + } + } + + public void LeaveChannel(string username, string channelName) + { + lock (_lock) + { + if (_userChannels.TryGetValue(username, out var channels)) + { + channels.Remove(channelName); + } + } + } + + public List GetOnlineUsersInChannel(string channelName) + { + var users = new List(); + + lock (_lock) + { + foreach (var (username, channels) in _userChannels) + { + if (channels.Contains(channelName)) + { + users.Add(username); + } + } + } + + return users; + } + + public List GetChannelsForUser(string username) + { + lock (_lock) + { + if (_userChannels.TryGetValue(username, out var channels)) + { + return channels.ToList(); + } + } + + return []; + } + + public bool IsOnline(string username) + { + return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; + } +} diff --git a/src/EchoHub.Server/appsettings.Development.json b/src/EchoHub.Server/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/EchoHub.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/EchoHub.Web/EchoHub.Web.csproj b/src/EchoHub.Web/EchoHub.Web.csproj new file mode 100644 index 0000000..989d10e --- /dev/null +++ b/src/EchoHub.Web/EchoHub.Web.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/EchoHub.Web/Program.cs b/src/EchoHub.Web/Program.cs new file mode 100644 index 0000000..eaed0ae --- /dev/null +++ b/src/EchoHub.Web/Program.cs @@ -0,0 +1,45 @@ +using EchoHub.Api.Data; +using EchoHub.Api.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? "Data Source=serverdirectory.db"; + +builder.Services.AddServerDirectory(connectionString); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); +} + +app.UseCors(); + +app.MapGet("/", () => Results.Content(""" + + + EchoHub + +

Welcome to EchoHub

+

EchoHub is a decentralized IRC-like chat network.

+

Use the Server Directory API to browse available servers.

+ + + """, "text/html")); + +app.MapServerDirectoryApi(); + +app.Run(); diff --git a/src/EchoHub.Web/Properties/launchSettings.json b/src/EchoHub.Web/Properties/launchSettings.json new file mode 100644 index 0000000..e262d5b --- /dev/null +++ b/src/EchoHub.Web/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:6000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7103;http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/EchoHub.Web/appsettings.Development.json b/src/EchoHub.Web/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/EchoHub.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +}