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": "*"
}