feat: Add Create Channel dialog and related events

- Introduced CreateChannelDialog for user to create new channels with name and topic.
- Added OnCreateChannelRequested event in MainWindow for channel creation.
- Enhanced EchoHubConnection with OnReconnected event for connection state handling.
- Updated EchoHubDbContext to use application base directory for SQLite database path.
- Integrated Serilog for logging in EchoHub.Server.
- Refactored database initialization and migration logic into DatabaseSetup class.
- Implemented FirstRunSetup to ensure appsettings.json and generate JWT secret if needed.
- Updated appsettings files to configure Serilog logging.
- Enhanced error handling and logging in ChatHub methods for better traceability.
This commit is contained in:
HueByte
2026-02-19 04:36:40 +01:00
parent ebc8fffec8
commit c9c76c43fa
15 changed files with 1191 additions and 962 deletions
+607
View File
@@ -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;
/// <summary>
/// Central orchestrator for the EchoHub TUI client.
/// Owns session state and wires UI events to service calls.
/// </summary>
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<Task> 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);
}
}