diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs new file mode 100644 index 0000000..0875670 --- /dev/null +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -0,0 +1,607 @@ +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; + +/// +/// Central orchestrator for the EchoHub TUI client. +/// Owns session state and wires UI events to service calls. +/// +public sealed class AppOrchestrator : IDisposable +{ + private readonly IApplication _app; + private readonly MainWindow _mainWindow; + private readonly CommandHandler _commandHandler; + + private EchoHubConnection? _connection; + private ApiClient? _apiClient; + private ClientConfig _config; + private UserStatus _currentStatus = UserStatus.Online; + private string? _currentStatusMessage; + private string _currentUsername = string.Empty; + + private bool IsConnected => _connection is not null && _connection.IsConnected; + private bool IsAuthenticated => _apiClient is not null; + + public MainWindow MainWindow => _mainWindow; + + public AppOrchestrator(IApplication app, ClientConfig config) + { + _app = app; + _config = config; + _mainWindow = new MainWindow(app); + _commandHandler = new CommandHandler(); + + WireMainWindowEvents(); + WireCommandHandlerEvents(); + + _mainWindow.UpdateStatusBar("Disconnected"); + } + + public void Dispose() + { + _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _apiClient?.Dispose(); + } + + // ── Convenience Helpers ──────────────────────────────────────────────── + + private void RunAsync(Func work, string errorPrefix, string? logContext = null) + { + AsyncRunner.Run(_app, work, _mainWindow.ShowError, errorPrefix, logContext); + } + + private void InvokeUI(Action action) => _app.Invoke(action); + + // ── MainWindow Event Wiring ──────────────────────────────────────────── + + private void WireMainWindowEvents() + { + _mainWindow.OnConnectRequested += HandleConnect; + _mainWindow.OnDisconnectRequested += HandleDisconnect; + _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; + _mainWindow.OnChannelSelected += HandleChannelSelected; + _mainWindow.OnProfileRequested += HandleProfileRequested; + _mainWindow.OnStatusRequested += HandleStatusRequested; + _mainWindow.OnThemeSelected += HandleThemeSelected; + _mainWindow.OnSavedServersRequested += HandleSavedServersRequested; + _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; + } + + // ── Command Handler Wiring ───────────────────────────────────────────── + + private void WireCommandHandlerEvents() + { + _commandHandler.OnSetStatus += async (status, message) => + { + if (!IsConnected) return; + + await _connection!.UpdateStatusAsync(status, message); + _currentStatus = status; + _currentStatusMessage = message; + }; + + _commandHandler.OnSetNick += async (displayName) => + { + if (!IsAuthenticated) return; + + await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName)); + InvokeUI(() => + { + _mainWindow.SetCurrentUser(displayName); + _mainWindow.UpdateStatusBar("Connected"); + }); + }; + + _commandHandler.OnSetColor += async (color) => + { + if (!IsAuthenticated) return; + + await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color)); + }; + + _commandHandler.OnSetTheme += (name) => + { + InvokeUI(() => HandleThemeSelected(name)); + return Task.CompletedTask; + }; + + _commandHandler.OnSendFile += async (target) => + { + if (!IsAuthenticated || !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")) + { + await _apiClient!.SendUrlAsync(channel, target); + } + else + { + 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 send failed for {Target}", target); + InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}")); + } + }; + + _commandHandler.OnOpenProfile += () => + { + InvokeUI(HandleProfileRequested); + return Task.CompletedTask; + }; + + _commandHandler.OnOpenServers += () => + { + InvokeUI(HandleSavedServersRequested); + return Task.CompletedTask; + }; + + _commandHandler.OnJoinChannel += async (channelName) => + { + if (!IsConnected) return; + + try + { + var history = await _connection!.JoinChannelAsync(channelName); + InvokeUI(() => + { + _mainWindow.SwitchToChannel(channelName); + if (history.Count > 0) + _mainWindow.LoadHistory(channelName, history); + }); + } + catch (Exception ex) + { + InvokeUI(() => _mainWindow.ShowError($"Failed to join channel: {ex.Message}")); + } + }; + + _commandHandler.OnLeaveChannel += async () => + { + if (!IsConnected) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + try + { + await _connection!.LeaveChannelAsync(channel); + InvokeUI(() => _mainWindow.AddSystemMessage(channel, $"You left #{channel}")); + } + catch (Exception ex) + { + InvokeUI(() => _mainWindow.ShowError($"Failed to leave channel: {ex.Message}")); + } + }; + + _commandHandler.OnSetTopic += async (topic) => + { + if (!IsAuthenticated) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + try + { + await _apiClient!.UpdateChannelTopicAsync(channel, topic); + InvokeUI(() => _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}")); + } + catch (Exception ex) + { + InvokeUI(() => _mainWindow.ShowError($"Failed to set topic: {ex.Message}")); + } + }; + + _commandHandler.OnListUsers += async () => + { + if (!IsConnected) return; + + var channel = _mainWindow.CurrentChannel; + if (string.IsNullOrEmpty(channel)) return; + + try + { + var users = await _connection!.GetOnlineUsersAsync(channel); + InvokeUI(() => + { + _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) + { + InvokeUI(() => _mainWindow.ShowError($"Failed to list users: {ex.Message}")); + } + }; + + _commandHandler.OnQuit += () => + { + InvokeUI(() => _app.RequestStop()); + return Task.CompletedTask; + }; + } + + // ── MainWindow Event Handlers ────────────────────────────────────────── + + private 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); + + RunAsync(async () => + { + _apiClient?.Dispose(); + _apiClient = new ApiClient(result.ServerUrl); + + InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating...")); + + var loginResponse = result.IsRegister + ? await _apiClient.RegisterAsync(result.Username, result.Password) + : await _apiClient.LoginAsync(result.Username, result.Password); + + _currentUsername = loginResponse.Username; + + InvokeUI(() => + { + _mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username); + _mainWindow.UpdateStatusBar("Authenticated, connecting..."); + }); + + if (_connection is not null) + await _connection.DisposeAsync(); + + _connection = new EchoHubConnection(result.ServerUrl, _apiClient); + WireConnectionEvents(_connection); + await _connection.ConnectAsync(); + + var channels = await _apiClient.GetChannelsAsync(); + InvokeUI(() => + { + _mainWindow.SetChannels(channels); + _mainWindow.UpdateStatusBar("Connected"); + }); + + await _connection.JoinChannelAsync(HubConstants.DefaultChannel); + InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel)); + + try + { + var history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel); + InvokeUI(() => + { + _mainWindow.LoadHistory(HubConstants.DefaultChannel, history); + _mainWindow.FocusInput(); + }); + } + catch + { + // History might not be available + } + + SaveServerToConfig(result); + }, "Connection failed", "Connect"); + } + + private void HandleDisconnect() + { + Log.Information("Disconnecting from server"); + + RunAsync(async () => + { + if (_connection is not null) + { + await _connection.DisconnectAsync(); + await _connection.DisposeAsync(); + _connection = null; + } + + _apiClient?.Dispose(); + _apiClient = null; + + InvokeUI(() => + { + _mainWindow.ClearAll(); + _mainWindow.UpdateStatusBar("Disconnected"); + }); + }, "Disconnect error", "Disconnect"); + } + + private void HandleMessageSubmitted(string channelName, string content) + { + if (!IsConnected) + { + _mainWindow.ShowError("Not connected to a server."); + return; + } + + if (_commandHandler.IsCommand(content)) + { + RunAsync(async () => + { + var result = await _commandHandler.HandleAsync(content); + if (result.Message is not null) + { + InvokeUI(() => + { + if (result.IsError) + _mainWindow.ShowError(result.Message); + else + _mainWindow.AddSystemMessage(channelName, result.Message); + }); + } + }, "Command failed"); + return; + } + + RunAsync( + async () => await _connection!.SendMessageAsync(channelName, content), + "Send failed"); + } + + private void HandleChannelSelected(string channelName) + { + if (!IsConnected) return; + + RunAsync(async () => + { + await _connection!.JoinChannelAsync(channelName); + + try + { + var history = await _connection.GetHistoryAsync(channelName); + InvokeUI(() => _mainWindow.LoadHistory(channelName, history)); + } + catch + { + // History might not be available + } + }, "Failed to join channel"); + } + + private void HandleProfileRequested() + { + Task.Run(async () => + { + UserProfileDto? profile = null; + try + { + if (IsAuthenticated && !string.IsNullOrEmpty(_currentUsername)) + profile = await _apiClient!.GetUserProfileAsync(_currentUsername); + } + catch + { + // Profile may not be available; continue with null + } + + InvokeUI(() => + { + var action = UserPanelDialog.Show(_app, + profile, + _config.SavedServers, + _currentStatus, + _currentStatusMessage); + + switch (action) + { + case UserPanelAction.EditProfile: + HandleEditProfile(profile); + break; + case UserPanelAction.SetStatus: + HandleStatusRequested(); + break; + } + }); + }); + } + + private void HandleEditProfile(UserProfileDto? currentProfile) + { + var editResult = ProfileEditDialog.Show(_app, + currentProfile?.DisplayName, + currentProfile?.Bio, + currentProfile?.NicknameColor); + + if (editResult is null) return; + + RunAsync(async () => + { + if (!IsAuthenticated) return; + + await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest( + editResult.DisplayName, + editResult.Bio, + editResult.NicknameColor)); + + if (editResult.DisplayName is not null) + { + InvokeUI(() => + { + _mainWindow.SetCurrentUser(editResult.DisplayName); + _mainWindow.UpdateStatusBar("Connected"); + }); + } + + _config.DefaultPreset = new AccountPreset + { + DisplayName = editResult.DisplayName, + Bio = editResult.Bio, + NicknameColor = editResult.NicknameColor + }; + ConfigManager.Save(_config); + }, "Profile update failed"); + } + + private void HandleStatusRequested() + { + var result = StatusDialog.Show(_app, _currentStatus, _currentStatusMessage); + if (result is null) return; + + _currentStatus = result.Status; + _currentStatusMessage = result.StatusMessage; + + if (IsConnected) + { + RunAsync( + async () => await _connection!.UpdateStatusAsync(result.Status, result.StatusMessage), + "Status update failed"); + } + } + + private void HandleThemeSelected(string themeName) + { + Log.Information("Theme selected: {Theme}", themeName); + + var theme = ThemeManager.GetTheme(themeName); + ThemeManager.ApplyTheme(theme); + + _config.ActiveTheme = themeName; + ConfigManager.Save(_config); + + InvokeUI(() => + { + _mainWindow.ApplyColorSchemes(); + _mainWindow.SetNeedsDraw(); + Log.Debug("Theme applied and UI refreshed"); + }); + } + + private 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(); + + MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); + } + + private void HandleCreateChannelRequested() + { + if (!IsAuthenticated || !IsConnected) + { + _mainWindow.ShowError("Not connected to a server."); + return; + } + + var result = CreateChannelDialog.Show(_app); + if (result is null) return; + + RunAsync(async () => + { + var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic); + if (channel is null) return; + + var history = await _connection!.JoinChannelAsync(channel.Name); + + // Refresh the channel list + var channels = await _apiClient.GetChannelsAsync(); + InvokeUI(() => + { + _mainWindow.SetChannels(channels); + _mainWindow.SwitchToChannel(channel.Name); + if (history.Count > 0) + _mainWindow.LoadHistory(channel.Name, history); + }); + }, "Failed to create channel"); + } + + // ── Connection Event Wiring ──────────────────────────────────────────── + + private void WireConnectionEvents(EchoHubConnection connection) + { + connection.OnMessageReceived += message => + InvokeUI(() => _mainWindow.AddMessage(message)); + + connection.OnUserJoined += (channelName, username) => + InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel")); + + connection.OnUserLeft += (channelName, username) => + InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel")); + + connection.OnUserStatusChanged += presence => + { + InvokeUI(() => + { + var displayName = presence.DisplayName ?? presence.Username; + var statusText = presence.Status.ToString(); + if (!string.IsNullOrWhiteSpace(presence.StatusMessage)) + statusText += $" - {presence.StatusMessage}"; + + foreach (var channelName in _mainWindow.GetChannelNames()) + _mainWindow.AddStatusMessage(channelName, displayName, statusText); + }); + }; + + connection.OnError += errorMessage => + InvokeUI(() => _mainWindow.ShowError(errorMessage)); + + connection.OnConnectionStateChanged += status => + InvokeUI(() => _mainWindow.UpdateStatusBar(status)); + + connection.OnReconnected += () => + { + var channels = _mainWindow.GetChannelNames(); + if (channels.Count == 0) return; + + RunAsync(async () => + { + foreach (var channel in channels) + await _connection!.JoinChannelAsync(channel); + + Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count); + }, "Failed to rejoin channels after reconnect"); + }; + } + + // ── Private Helpers ──────────────────────────────────────────────────── + + private void SaveServerToConfig(ConnectDialogResult result) + { + 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); + } +} diff --git a/src/EchoHub.Client/AsyncRunner.cs b/src/EchoHub.Client/AsyncRunner.cs new file mode 100644 index 0000000..24a0316 --- /dev/null +++ b/src/EchoHub.Client/AsyncRunner.cs @@ -0,0 +1,32 @@ +using Serilog; +using Terminal.Gui.App; + +namespace EchoHub.Client; + +/// +/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate. +/// Runs async work on a background thread and routes exceptions to the UI. +/// +public static class AsyncRunner +{ + public static void Run( + IApplication app, + Func work, + Action showError, + string errorPrefix, + string? logContext = null) + { + Task.Run(async () => + { + try + { + await work(); + } + catch (Exception ex) + { + Log.Error(ex, "{Context} failed", logContext ?? errorPrefix); + app.Invoke(() => showError($"{errorPrefix}: {ex.Message}")); + } + }); + } +} diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index eba39e2..30f5f20 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -1,702 +1,43 @@ -using EchoHub.Client.Commands; +using EchoHub.Client; 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 Microsoft.Extensions.Configuration; using Serilog; using Terminal.Gui.App; -using Terminal.Gui.Views; -namespace EchoHub.Client; +var configuration = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .Build(); -public static class Program +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .CreateLogger(); + +Log.Information("EchoHub client starting"); + +try { - 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; + var config = ConfigManager.Load(); + Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme); - public static void Main() - { - // Configure Serilog from appsettings.json - var configuration = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) - .Build(); + var app = Application.Create().Init(); - Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(configuration) - .CreateLogger(); + var theme = ThemeManager.GetTheme(config.ActiveTheme); + ThemeManager.ApplyTheme(theme); - Log.Information("EchoHub client starting"); + using var orchestrator = new AppOrchestrator(app, config); - 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")) - { - // Send URL to server — server handles downloading and conversion - await _apiClient.SendUrlAsync(channel, target); - } - else - { - // Local file — upload to server - 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 send failed for {Target}", target); - _app.Invoke(() => - _mainWindow!.ShowError($"Send 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) => - { - if (_apiClient is null) - return; - - var channel = _mainWindow!.CurrentChannel; - if (string.IsNullOrEmpty(channel)) - return; - - try - { - await _apiClient.UpdateChannelTopicAsync(channel, topic); - _app.Invoke(() => - _mainWindow!.AddSystemMessage(channel, $"Topic set to: {topic}")); - } - catch (Exception ex) - { - _app.Invoke(() => - _mainWindow!.ShowError($"Failed to set topic: {ex.Message}")); - } - }; - - _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); - 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)); - }; - } + app.Run(orchestrator.MainWindow); + app.Dispose(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "EchoHub client crashed"); + throw; +} +finally +{ + Log.Information("EchoHub client shutting down"); + Log.CloseAndFlush(); } diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 61b965e..d5fc78d 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -16,6 +16,7 @@ public sealed class EchoHubConnection : IAsyncDisposable public event Action? OnUserStatusChanged; public event Action? OnError; public event Action? OnConnectionStateChanged; + public event Action? OnReconnected; public bool IsConnected => _connection.State == HubConnectionState.Connected; @@ -42,6 +43,7 @@ public sealed class EchoHubConnection : IAsyncDisposable _connection.Reconnected += _ => { OnConnectionStateChanged?.Invoke("Connected"); + OnReconnected?.Invoke(); return Task.CompletedTask; }; diff --git a/src/EchoHub.Client/UI/CreateChannelDialog.cs b/src/EchoHub.Client/UI/CreateChannelDialog.cs new file mode 100644 index 0000000..8912d1e --- /dev/null +++ b/src/EchoHub.Client/UI/CreateChannelDialog.cs @@ -0,0 +1,77 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; + +namespace EchoHub.Client.UI; + +public record CreateChannelResult(string Name, string? Topic); + +public sealed class CreateChannelDialog +{ + public static CreateChannelResult? Show(IApplication app) + { + CreateChannelResult? result = null; + + var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 }; + + var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 }; + var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) }; + + var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 }; + var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) }; + + var hintLabel = new Label + { + Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)", + X = 1, + Y = 5, + }; + + var createButton = new Button + { + Text = "Create", + IsDefault = true, + X = Pos.Center() - 10, + Y = 7 + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center() + 5, + Y = 7 + }; + + createButton.Accepting += (s, e) => + { + var name = nameField.Text?.Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK"); + return; + } + + var topic = topicField.Text?.Trim(); + if (string.IsNullOrWhiteSpace(topic)) + topic = null; + + result = new CreateChannelResult(name, topic); + e.Handled = true; + app.RequestStop(); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton); + + nameField.SetFocus(); + app.Run(dialog); + + return result; + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 7d8cce4..72da4c8 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -74,6 +74,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnSavedServersRequested; + /// + /// Fired when the user requests to create a new channel. + /// + public event Action? OnCreateChannelRequested; + public MainWindow(IApplication app) { _app = app; @@ -228,6 +233,7 @@ public sealed class MainWindow : Runnable new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new Line(), + new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty) }), new MenuBarItem("_User", allUserItems) diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 678b048..6927cde 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -15,7 +15,8 @@ public class EchoHubDbContext(DbContextOptions options) : DbCo { if (!optionsBuilder.IsConfigured) { - optionsBuilder.UseSqlite("Data Source=echohub.db"); + var dbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db"); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); } } diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj index 74fe8f0..30038bd 100644 --- a/src/EchoHub.Server/EchoHub.Server.csproj +++ b/src/EchoHub.Server/EchoHub.Server.csproj @@ -12,6 +12,7 @@ all + diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 24e7d73..31d5f44 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -24,246 +24,313 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack public override async Task OnConnectedAsync() { - presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername); - - var user = await db.Users.FindAsync(CurrentUserId); - if (user is not null) + try { - user.LastSeenAt = DateTimeOffset.UtcNow; - user.Status = UserStatus.Online; - await db.SaveChangesAsync(); + 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); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in OnConnectedAsync for {ConnectionId}", Context.ConnectionId); + throw; } - - await base.OnConnectedAsync(); - - logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId); } public override async Task OnDisconnectedAsync(Exception? exception) { - 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)) + try { - var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); - if (user is not null) + 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)) { - 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) + var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username); + if (user is not null) { - await Clients.Group(channel).UserStatusChanged(presence); + 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); + } + catch (Exception ex) + { + logger.LogError(ex, "Error in OnDisconnectedAsync for {ConnectionId}", Context.ConnectionId); + throw; } - - await base.OnDisconnectedAsync(exception); - - logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId); } public async Task> JoinChannel(string channelName) { - channelName = channelName.ToLowerInvariant().Trim(); - - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + try { - await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + { + await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); + return []; + } + + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + + if (channel is null) + { + await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list."); + return []; + } + + 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; + } + catch (Exception ex) + { + logger.LogError(ex, "Error joining channel '{Channel}' for {User}", channelName, CurrentUsername); + await Clients.Caller.Error($"Failed to join channel: {ex.Message}"); return []; } - - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - - if (channel is null) - { - await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list."); - return []; - } - - 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(); + try + { + channelName = channelName.ToLowerInvariant().Trim(); - presenceTracker.LeaveChannel(CurrentUsername, channelName); + presenceTracker.LeaveChannel(CurrentUsername, channelName); - await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName); - await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName); + await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername); - logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName); + logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName); + } + catch (Exception ex) + { + logger.LogError(ex, "Error leaving channel '{Channel}' for {User}", channelName, CurrentUsername); + await Clients.Caller.Error($"Failed to leave channel: {ex.Message}"); + } } public async Task SendMessage(string channelName, string content) { - channelName = channelName.ToLowerInvariant().Trim(); - - if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + try { - await Clients.Caller.Error("Invalid channel name."); - return; + channelName = channelName.ToLowerInvariant().Trim(); + + if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) + { + await Clients.Caller.Error("Invalid channel name."); + return; + } + + 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); } - - if (string.IsNullOrWhiteSpace(content)) + catch (Exception ex) { - await Clients.Caller.Error("Message content cannot be empty."); - return; + logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername); + await Clients.Caller.Error($"Failed to send message: {ex.Message}"); } - - 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(); - count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); + try + { + channelName = channelName.ToLowerInvariant().Trim(); + count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); - var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); + var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); - if (channel is null) + 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; + } + catch (Exception ex) + { + logger.LogError(ex, "Error fetching history for '{Channel}'", channelName); + await Clients.Caller.Error($"Failed to load history: {ex.Message}"); 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) { - if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) + try { - await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."); - return; + if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength) + { + await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters."); + return; + } + + 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?.Trim(); + 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); + } } - - var user = await db.Users.FindAsync(CurrentUserId); - - if (user is null) + catch (Exception ex) { - await Clients.Caller.Error("User not found."); - return; - } - - user.Status = status; - user.StatusMessage = statusMessage?.Trim(); - 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); + logger.LogError(ex, "Error updating status for {User}", CurrentUsername); + await Clients.Caller.Error($"Failed to update status: {ex.Message}"); } } public async Task> GetOnlineUsers(string channelName) { - channelName = channelName.ToLowerInvariant().Trim(); + try + { + channelName = channelName.ToLowerInvariant().Trim(); - var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName); + 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(); + 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; + return users; + } + catch (Exception ex) + { + logger.LogError(ex, "Error listing users in '{Channel}'", channelName); + await Clients.Caller.Error($"Failed to list users: {ex.Message}"); + return []; + } } } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index c1917b7..d473ded 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -1,21 +1,31 @@ using System.Text; using System.Threading.RateLimiting; using EchoHub.Core.Constants; -using Microsoft.AspNetCore.RateLimiting; using EchoHub.Core.Models; using EchoHub.Server.Auth; using EchoHub.Server.Data; using EchoHub.Server.Hubs; using EchoHub.Server.Services; +using EchoHub.Server.Setup; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Serilog; + +// ── First-run setup ────────────────────────────────────────────────────────── +FirstRunSetup.EnsureAppSettings(); var builder = WebApplication.CreateBuilder(args); +// ── Serilog ────────────────────────────────────────────────────────────────── +builder.Host.UseSerilog((context, config) => + config.ReadFrom.Configuration(context.Configuration)); + // ── SQLite + EF Core ────────────────────────────────────────────────────────── +var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db"); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") - ?? "Data Source=echohub.db"; + ?? $"Data Source={defaultDbPath}"; builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); @@ -127,81 +137,7 @@ builder.Services.AddCors(options => var app = builder.Build(); // ── Database initialization ─────────────────────────────────────────────────── -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - var logger = scope.ServiceProvider.GetRequiredService>(); - - try - { - // If the DB was created by EnsureCreated (no __EFMigrationsHistory table), - // back it up and recreate so MigrateAsync can manage the schema properly. - if (await db.Database.CanConnectAsync()) - { - var conn = db.Database.GetDbConnection(); - await conn.OpenAsync(); - - using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; - var hasMigrationTable = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - - if (!hasMigrationTable) - { - cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Users'"; - var hasLegacyTables = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - - if (hasLegacyTables) - { - var dbPath = conn.DataSource; - await conn.CloseAsync(); - - // Back up the legacy DB file before deleting - if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) - { - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - var backupPath = $"{dbPath}.legacy_{timestamp}"; - File.Copy(dbPath, backupPath, overwrite: false); - logger.LogWarning("Legacy database backed up to '{BackupPath}'.", backupPath); - } - - await db.Database.EnsureDeletedAsync(); - logger.LogWarning("Legacy database removed. A new database will be created with migration support."); - } - else - { - await conn.CloseAsync(); - } - } - else - { - await conn.CloseAsync(); - } - } - - await db.Database.MigrateAsync(); - logger.LogInformation("Database migrated successfully."); - } - catch (Exception ex) - { - logger.LogError(ex, "Database migration failed."); - throw; - } - - // Seed the default channel if it doesn't exist - 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(); - logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel); - } -} +await DatabaseSetup.InitializeAsync(app.Services); // ── Middleware ───────────────────────────────────────────────────────────────── app.UseCors(); diff --git a/src/EchoHub.Server/Services/FileStorageService.cs b/src/EchoHub.Server/Services/FileStorageService.cs index 7eb7850..a34d3cd 100644 --- a/src/EchoHub.Server/Services/FileStorageService.cs +++ b/src/EchoHub.Server/Services/FileStorageService.cs @@ -6,7 +6,8 @@ public class FileStorageService public FileStorageService(IConfiguration configuration) { - _storagePath = configuration["Storage:Path"] ?? "./uploads"; + _storagePath = configuration["Storage:Path"] + ?? Path.Combine(AppContext.BaseDirectory, "uploads"); if (!Directory.Exists(_storagePath)) { diff --git a/src/EchoHub.Server/Setup/DatabaseSetup.cs b/src/EchoHub.Server/Setup/DatabaseSetup.cs new file mode 100644 index 0000000..afa63dd --- /dev/null +++ b/src/EchoHub.Server/Setup/DatabaseSetup.cs @@ -0,0 +1,90 @@ +using EchoHub.Core.Constants; +using EchoHub.Core.Models; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; + +namespace EchoHub.Server.Setup; + +public static class DatabaseSetup +{ + public static async Task InitializeAsync(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService() + .CreateLogger("EchoHub.Server.Setup.DatabaseSetup"); + + await MigrateAsync(db, logger); + await SeedDefaultChannelAsync(db, logger); + } + + private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger) + { + try + { + if (await db.Database.CanConnectAsync()) + await HandleLegacyDatabaseAsync(db, logger); + + await db.Database.MigrateAsync(); + logger.LogInformation("Database migrated successfully."); + } + catch (Exception ex) + { + logger.LogError(ex, "Database migration failed."); + throw; + } + } + + private static async Task HandleLegacyDatabaseAsync(EchoHubDbContext db, ILogger logger) + { + var conn = db.Database.GetDbConnection(); + await conn.OpenAsync(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; + var hasMigrationTable = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; + + if (!hasMigrationTable) + { + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Users'"; + var hasLegacyTables = Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; + + if (hasLegacyTables) + { + var dbPath = conn.DataSource; + await conn.CloseAsync(); + + if (!string.IsNullOrEmpty(dbPath) && File.Exists(dbPath)) + { + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var backupPath = $"{dbPath}.legacy_{timestamp}"; + File.Copy(dbPath, backupPath, overwrite: false); + logger.LogWarning("Legacy database backed up to '{BackupPath}'.", backupPath); + } + + await db.Database.EnsureDeletedAsync(); + logger.LogWarning("Legacy database removed. A new database will be created with migration support."); + return; + } + } + + await conn.CloseAsync(); + } + + private static async Task SeedDefaultChannelAsync(EchoHubDbContext db, ILogger logger) + { + if (await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) + return; + + db.Channels.Add(new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }); + + await db.SaveChangesAsync(); + logger.LogInformation("Default channel '{Channel}' created.", HubConstants.DefaultChannel); + } +} diff --git a/src/EchoHub.Server/Setup/FirstRunSetup.cs b/src/EchoHub.Server/Setup/FirstRunSetup.cs new file mode 100644 index 0000000..150d861 --- /dev/null +++ b/src/EchoHub.Server/Setup/FirstRunSetup.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace EchoHub.Server.Setup; + +public static class FirstRunSetup +{ + public static void EnsureAppSettings() + { + var contentRoot = Directory.GetCurrentDirectory(); + var settingsPath = Path.Combine(contentRoot, "appsettings.json"); + var examplePath = Path.Combine(contentRoot, "appsettings.example.json"); + + if (!File.Exists(settingsPath) && File.Exists(examplePath)) + { + File.Copy(examplePath, settingsPath); + Console.WriteLine("Created appsettings.json from example config."); + } + + if (!File.Exists(settingsPath)) + return; + + EnsureJwtSecret(settingsPath); + } + + private static void EnsureJwtSecret(string settingsPath) + { + var json = File.ReadAllText(settingsPath); + var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); + if (root is null) + return; + + var currentSecret = root["Jwt"]?["Secret"]?.GetValue(); + + if (!string.IsNullOrEmpty(currentSecret) && !currentSecret.StartsWith("CHANGE_ME")) + return; + + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)); + + root["Jwt"] ??= new JsonObject(); + root["Jwt"]!["Secret"] = secret; + + var writeOptions = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(settingsPath, root.ToJsonString(writeOptions)); + Console.WriteLine("Generated new JWT secret in appsettings.json."); + } +} diff --git a/src/EchoHub.Server/appsettings.Development.json b/src/EchoHub.Server/appsettings.Development.json index ff66ba6..fb95338 100644 --- a/src/EchoHub.Server/appsettings.Development.json +++ b/src/EchoHub.Server/appsettings.Development.json @@ -1,8 +1,12 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "System": "Warning" + } } } } diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 974ea31..1ccec93 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -11,12 +11,28 @@ "Name": "My EchoHub Server", "Description": "A self-hosted EchoHub chat server" }, - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "File", + "Args": { + "path": "logs/echohub-server-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 14, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}" + } + } + ] }, "AllowedHosts": "*" }