mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
|
namespace EchoHub.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
||||||
|
/// Runs async work on a background thread and routes exceptions to the UI.
|
||||||
|
/// </summary>
|
||||||
|
public static class AsyncRunner
|
||||||
|
{
|
||||||
|
public static void Run(
|
||||||
|
IApplication app,
|
||||||
|
Func<Task> work,
|
||||||
|
Action<string> 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}"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-689
@@ -1,702 +1,43 @@
|
|||||||
using EchoHub.Client.Commands;
|
using EchoHub.Client;
|
||||||
using EchoHub.Client.Config;
|
using EchoHub.Client.Config;
|
||||||
using EchoHub.Client.Services;
|
|
||||||
using EchoHub.Client.Themes;
|
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 Microsoft.Extensions.Configuration;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
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!;
|
var config = ConfigManager.Load();
|
||||||
private static EchoHubConnection? _connection;
|
Log.Information("Configuration loaded, active theme: {Theme}", config.ActiveTheme);
|
||||||
private static ApiClient? _apiClient;
|
|
||||||
private static MainWindow? _mainWindow;
|
|
||||||
private static CommandHandler? _commandHandler;
|
|
||||||
private static ClientConfig _config = new();
|
|
||||||
private static UserStatus _currentStatus = UserStatus.Online;
|
|
||||||
private static string? _currentStatusMessage;
|
|
||||||
private static string _currentUsername = string.Empty;
|
|
||||||
|
|
||||||
public static void Main()
|
var app = Application.Create().Init();
|
||||||
{
|
|
||||||
// Configure Serilog from appsettings.json
|
|
||||||
var configuration = new ConfigurationBuilder()
|
|
||||||
.SetBasePath(AppContext.BaseDirectory)
|
|
||||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
var theme = ThemeManager.GetTheme(config.ActiveTheme);
|
||||||
.ReadFrom.Configuration(configuration)
|
ThemeManager.ApplyTheme(theme);
|
||||||
.CreateLogger();
|
|
||||||
|
|
||||||
Log.Information("EchoHub client starting");
|
using var orchestrator = new AppOrchestrator(app, config);
|
||||||
|
|
||||||
try
|
app.Run(orchestrator.MainWindow);
|
||||||
{
|
app.Dispose();
|
||||||
// Load configuration
|
}
|
||||||
_config = ConfigManager.Load();
|
catch (Exception ex)
|
||||||
Log.Information("Configuration loaded, active theme: {Theme}", _config.ActiveTheme);
|
{
|
||||||
|
Log.Fatal(ex, "EchoHub client crashed");
|
||||||
_app = Application.Create().Init();
|
throw;
|
||||||
|
}
|
||||||
// Apply our custom color scheme
|
finally
|
||||||
var theme = Themes.ThemeManager.GetTheme(_config.ActiveTheme);
|
{
|
||||||
Themes.ThemeManager.ApplyTheme(theme);
|
Log.Information("EchoHub client shutting down");
|
||||||
|
Log.CloseAndFlush();
|
||||||
_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));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
public event Action<UserPresenceDto>? OnUserStatusChanged;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
public event Action<string>? OnConnectionStateChanged;
|
public event Action<string>? OnConnectionStateChanged;
|
||||||
|
public event Action? OnReconnected;
|
||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
_connection.Reconnected += _ =>
|
_connection.Reconnected += _ =>
|
||||||
{
|
{
|
||||||
OnConnectionStateChanged?.Invoke("Connected");
|
OnConnectionStateChanged?.Invoke("Connected");
|
||||||
|
OnReconnected?.Invoke();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action? OnSavedServersRequested;
|
public event Action? OnSavedServersRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user requests to create a new channel.
|
||||||
|
/// </summary>
|
||||||
|
public event Action? OnCreateChannelRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
public MainWindow(IApplication app)
|
||||||
{
|
{
|
||||||
_app = 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("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||||
new Line(),
|
new Line(),
|
||||||
|
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
|
||||||
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
||||||
}),
|
}),
|
||||||
new MenuBarItem("_User", allUserItems)
|
new MenuBarItem("_User", allUserItems)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class EchoHubDbContext(DbContextOptions<EchoHubDbContext> options) : DbCo
|
|||||||
{
|
{
|
||||||
if (!optionsBuilder.IsConfigured)
|
if (!optionsBuilder.IsConfigured)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseSqlite("Data Source=echohub.db");
|
var dbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
||||||
|
optionsBuilder.UseSqlite($"Data Source={dbPath}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+252
-185
@@ -24,246 +24,313 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
|
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
try
|
||||||
|
|
||||||
var user = await db.Users.FindAsync(CurrentUserId);
|
|
||||||
if (user is not null)
|
|
||||||
{
|
{
|
||||||
user.LastSeenAt = DateTimeOffset.UtcNow;
|
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
||||||
user.Status = UserStatus.Online;
|
|
||||||
await db.SaveChangesAsync();
|
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)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
try
|
||||||
var channelsBeforeDisconnect = preDisconnectUsername is not null
|
|
||||||
? presenceTracker.GetChannelsForUser(preDisconnectUsername)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
var username = presenceTracker.UserDisconnected(Context.ConnectionId);
|
|
||||||
|
|
||||||
if (username is not null && !presenceTracker.IsOnline(username))
|
|
||||||
{
|
{
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
||||||
if (user is not null)
|
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;
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
user.Status = UserStatus.Invisible;
|
if (user is not null)
|
||||||
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);
|
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<List<MessageDto>> JoinChannel(string channelName)
|
public async Task<List<MessageDto>> JoinChannel(string channelName)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
try
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
|
||||||
{
|
{
|
||||||
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 [];
|
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)
|
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 Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
|
||||||
await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
|
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)
|
public async Task SendMessage(string channelName, string content)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
try
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
|
||||||
{
|
{
|
||||||
await Clients.Caller.Error("Invalid channel name.");
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
return;
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
{
|
{
|
||||||
await Clients.Caller.Error("Message content cannot be empty.");
|
logger.LogError(ex, "Error sending message in '{Channel}' for {User}", channelName, CurrentUsername);
|
||||||
return;
|
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<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
try
|
||||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
{
|
||||||
|
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 [];
|
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)
|
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.");
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
return;
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
var user = await db.Users.FindAsync(CurrentUserId);
|
|
||||||
|
|
||||||
if (user is null)
|
|
||||||
{
|
{
|
||||||
await Clients.Caller.Error("User not found.");
|
logger.LogError(ex, "Error updating status for {User}", CurrentUsername);
|
||||||
return;
|
await Clients.Caller.Error($"Failed to update status: {ex.Message}");
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<UserPresenceDto>> GetOnlineUsers(string channelName)
|
public async Task<List<UserPresenceDto>> 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
|
var users = await db.Users
|
||||||
.Where(u => onlineUsernames.Contains(u.Username))
|
.Where(u => onlineUsernames.Contains(u.Username))
|
||||||
.Select(u => new UserPresenceDto(
|
.Select(u => new UserPresenceDto(
|
||||||
u.Username,
|
u.Username,
|
||||||
u.DisplayName,
|
u.DisplayName,
|
||||||
u.NicknameColor,
|
u.NicknameColor,
|
||||||
u.Status,
|
u.Status,
|
||||||
u.StatusMessage))
|
u.StatusMessage))
|
||||||
.ToListAsync();
|
.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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Hubs;
|
using EchoHub.Server.Hubs;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Setup;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
// ── First-run setup ──────────────────────────────────────────────────────────
|
||||||
|
FirstRunSetup.EnsureAppSettings();
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Serilog ──────────────────────────────────────────────────────────────────
|
||||||
|
builder.Host.UseSerilog((context, config) =>
|
||||||
|
config.ReadFrom.Configuration(context.Configuration));
|
||||||
|
|
||||||
// ── SQLite + EF Core ──────────────────────────────────────────────────────────
|
// ── SQLite + EF Core ──────────────────────────────────────────────────────────
|
||||||
|
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? "Data Source=echohub.db";
|
?? $"Data Source={defaultDbPath}";
|
||||||
|
|
||||||
builder.Services.AddDbContext<EchoHubDbContext>(options =>
|
builder.Services.AddDbContext<EchoHubDbContext>(options =>
|
||||||
options.UseSqlite(connectionString));
|
options.UseSqlite(connectionString));
|
||||||
@@ -127,81 +137,7 @@ builder.Services.AddCors(options =>
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// ── Database initialization ───────────────────────────────────────────────────
|
// ── Database initialization ───────────────────────────────────────────────────
|
||||||
using (var scope = app.Services.CreateScope())
|
await DatabaseSetup.InitializeAsync(app.Services);
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Middleware ─────────────────────────────────────────────────────────────────
|
// ── Middleware ─────────────────────────────────────────────────────────────────
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ public class FileStorageService
|
|||||||
|
|
||||||
public FileStorageService(IConfiguration configuration)
|
public FileStorageService(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_storagePath = configuration["Storage:Path"] ?? "./uploads";
|
_storagePath = configuration["Storage:Path"]
|
||||||
|
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
if (!Directory.Exists(_storagePath))
|
if (!Directory.Exists(_storagePath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<EchoHubDbContext>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Debug",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": {
|
||||||
|
"Microsoft": "Warning",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System": "Warning"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,28 @@
|
|||||||
"Name": "My EchoHub Server",
|
"Name": "My EchoHub Server",
|
||||||
"Description": "A self-hosted EchoHub chat server"
|
"Description": "A self-hosted EchoHub chat server"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Override": {
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
"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": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user