Add core models, DTOs, and services for EchoHub chat application

- Created User, Channel, Message, and ServerInfo models with necessary properties.
- Added DTOs for user profiles, server information, and message handling.
- Implemented JWT authentication service for user login and token generation.
- Developed Entity Framework Core DbContext for database interactions.
- Introduced SignalR ChatHub for real-time messaging and presence tracking.
- Implemented file storage and image to ASCII conversion services.
- Set up CORS and middleware for API endpoints.
- Created launch settings and development configuration for server and web projects.
This commit is contained in:
HueByte
2026-02-18 13:52:19 +01:00
parent 20a341947a
commit cfa8b90d04
47 changed files with 4596 additions and 0 deletions
@@ -0,0 +1,224 @@
using EchoHub.Core.Models;
namespace EchoHub.Client.Commands;
public record CommandResult(bool Handled, string? Message = null, bool IsError = false);
public class CommandHandler
{
public event Func<UserStatus, string?, Task>? OnSetStatus;
public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme;
public event Func<string, Task>? OnSendFile;
public event Func<Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel;
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
public event Func<Task>? OnQuit;
public event Func<Task>? OnHelp;
public bool IsCommand(string input) => input.StartsWith('/');
public async Task<CommandResult> HandleAsync(string input)
{
if (!IsCommand(input))
return new CommandResult(false);
var parts = input[1..].Split(' ', 2, StringSplitOptions.TrimEntries);
var command = parts[0].ToLowerInvariant();
var args = parts.Length > 1 ? parts[1] : string.Empty;
return command switch
{
"status" => await HandleStatus(args),
"nick" => await HandleNick(args),
"color" => await HandleColor(args),
"theme" => await HandleTheme(args),
"send" => await HandleSend(args),
"profile" => await HandleProfile(),
"servers" => await HandleServers(),
"join" => await HandleJoin(args),
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
"quit" or "exit" => await HandleQuit(),
"help" or "?" => await HandleHelp(),
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
};
}
private async Task<CommandResult> HandleStatus(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
var statusArg = args.ToLowerInvariant().Trim();
UserStatus? status = statusArg switch
{
"online" => UserStatus.Online,
"away" => UserStatus.Away,
"dnd" or "donotdisturb" => UserStatus.DoNotDisturb,
"invisible" => UserStatus.Invisible,
_ => null,
};
if (status.HasValue)
{
if (OnSetStatus is not null)
await OnSetStatus(status.Value, null);
return new CommandResult(true, $"Status set to {status.Value}");
}
// Treat as custom status message (keep current status)
if (OnSetStatus is not null)
await OnSetStatus(UserStatus.Online, args);
return new CommandResult(true, $"Status message set: {args}");
}
private async Task<CommandResult> HandleNick(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /nick <display name>", IsError: true);
if (OnSetNick is not null)
await OnSetNick(args.Trim());
return new CommandResult(true, $"Display name set to: {args.Trim()}");
}
private async Task<CommandResult> HandleColor(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /color <hex> (e.g., /color #FF5733)", IsError: true);
var color = args.Trim();
if (!color.StartsWith('#'))
color = "#" + color;
if (color.Length != 7 || !IsValidHex(color[1..]))
return new CommandResult(true, "Invalid color. Use hex format: #RRGGBB", IsError: true);
if (OnSetColor is not null)
await OnSetColor(color);
return new CommandResult(true, $"Nickname color set to: {color}");
}
private async Task<CommandResult> HandleTheme(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /theme <name> (Default, Dark, Light, Hacker, Solarized)", IsError: true);
if (OnSetTheme is not null)
await OnSetTheme(args.Trim());
return new CommandResult(true, $"Theme switched to: {args.Trim()}");
}
private async Task<CommandResult> HandleSend(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true);
var target = args.Trim().Trim('"');
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
if (OnSendFile is not null)
await OnSendFile(target);
var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName))
fileName = "download";
return new CommandResult(true, $"Downloading & uploading: {fileName}...");
}
if (!File.Exists(target))
return new CommandResult(true, $"File not found: {target}", IsError: true);
if (OnSendFile is not null)
await OnSendFile(target);
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
}
private async Task<CommandResult> HandleProfile()
{
if (OnOpenProfile is not null)
await OnOpenProfile();
return new CommandResult(true);
}
private async Task<CommandResult> HandleServers()
{
if (OnOpenServers is not null)
await OnOpenServers();
return new CommandResult(true);
}
private async Task<CommandResult> HandleJoin(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /join <channel>", IsError: true);
var channel = args.Trim().TrimStart('#');
if (OnJoinChannel is not null)
await OnJoinChannel(channel);
return new CommandResult(true);
}
private async Task<CommandResult> HandleLeave()
{
if (OnLeaveChannel is not null)
await OnLeaveChannel();
return new CommandResult(true);
}
private async Task<CommandResult> HandleTopic(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /topic <text>", IsError: true);
if (OnSetTopic is not null)
await OnSetTopic(args.Trim());
return new CommandResult(true, $"Topic set to: {args.Trim()}");
}
private async Task<CommandResult> HandleUsers()
{
if (OnListUsers is not null)
await OnListUsers();
return new CommandResult(true);
}
private async Task<CommandResult> HandleQuit()
{
if (OnQuit is not null)
await OnQuit();
return new CommandResult(true);
}
private async Task<CommandResult> HandleHelp()
{
if (OnHelp is not null)
await OnHelp();
return new CommandResult(true, """
Available commands:
/status <online|away|dnd|invisible> - Set your status
/status <message> - Set status message
/nick <name> - Set display name
/color <#hex> - Set nickname color
/theme <name> - Switch theme
/send <filepath or URL> - Send a file or image
/profile - Open your profile
/servers - Open saved servers
/join <channel> - Join a channel
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
/quit - Exit the app
""");
}
private static bool IsValidHex(string s) =>
s.All(c => char.IsAsciiHexDigit(c));
}
+24
View File
@@ -0,0 +1,24 @@
namespace EchoHub.Client.Config;
public class ClientConfig
{
public List<SavedServer> SavedServers { get; set; } = [];
public AccountPreset DefaultPreset { get; set; } = new();
public string ActiveTheme { get; set; } = "Default";
}
public class SavedServer
{
public required string Name { get; set; }
public required string Url { get; set; }
public string? Username { get; set; }
public string? Token { get; set; }
public DateTimeOffset LastConnected { get; set; }
}
public class AccountPreset
{
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? NicknameColor { get; set; }
}
@@ -0,0 +1,70 @@
using System.Text.Json;
namespace EchoHub.Client.Config;
public static class ConfigManager
{
private static readonly string ConfigDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub");
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static ClientConfig Load()
{
try
{
if (!File.Exists(ConfigPath))
return new ClientConfig();
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<ClientConfig>(json, JsonOptions) ?? new ClientConfig();
}
catch
{
return new ClientConfig();
}
}
public static void Save(ClientConfig config)
{
try
{
Directory.CreateDirectory(ConfigDir);
var json = JsonSerializer.Serialize(config, JsonOptions);
File.WriteAllText(ConfigPath, json);
}
catch
{
// Silently fail — config save is best-effort
}
}
public static void SaveServer(SavedServer server)
{
var config = Load();
var existing = config.SavedServers.FindIndex(s =>
string.Equals(s.Url, server.Url, StringComparison.OrdinalIgnoreCase));
if (existing >= 0)
config.SavedServers[existing] = server;
else
config.SavedServers.Add(server);
Save(config);
}
public static void RemoveServer(string url)
{
var config = Load();
config.SavedServers.RemoveAll(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
Save(config);
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+717
View File
@@ -0,0 +1,717 @@
using EchoHub.Client.Commands;
using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using EchoHub.Client.UI;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Views;
namespace EchoHub.Client;
public static class Program
{
private static IApplication _app = null!;
private static EchoHubConnection? _connection;
private static ApiClient? _apiClient;
private static MainWindow? _mainWindow;
private static CommandHandler? _commandHandler;
private static ClientConfig _config = new();
private static UserStatus _currentStatus = UserStatus.Online;
private static string? _currentStatusMessage;
private static string _currentUsername = string.Empty;
public static void Main()
{
// Configure Serilog file logger
var logDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "logs");
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(
Path.Combine(logDir, "echohub-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Log.Information("EchoHub client starting");
try
{
// Load configuration
_config = ConfigManager.Load();
Log.Information("Configuration loaded, active theme: {Theme}", _config.ActiveTheme);
_app = Application.Create().Init();
// Apply our custom color scheme
var theme = Themes.ThemeManager.GetTheme(_config.ActiveTheme);
Themes.ThemeManager.ApplyTheme(theme);
_mainWindow = new MainWindow(_app);
_commandHandler = new CommandHandler();
// Wire MainWindow events
_mainWindow.OnConnectRequested += HandleConnect;
_mainWindow.OnDisconnectRequested += HandleDisconnect;
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
_mainWindow.OnChannelSelected += HandleChannelSelected;
_mainWindow.OnProfileRequested += HandleProfileRequested;
_mainWindow.OnStatusRequested += HandleStatusRequested;
_mainWindow.OnThemeSelected += HandleThemeSelected;
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
// Wire CommandHandler events
WireCommandHandlerEvents();
_mainWindow.UpdateStatusBar("Disconnected");
_app.Run(_mainWindow);
_app.Dispose();
// Cleanup
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_apiClient?.Dispose();
}
catch (Exception ex)
{
Log.Fatal(ex, "EchoHub client crashed");
throw;
}
finally
{
Log.Information("EchoHub client shutting down");
Log.CloseAndFlush();
}
}
// ── Command Handler Wiring ─────────────────────────────────────────────
private static void WireCommandHandlerEvents()
{
_commandHandler!.OnSetStatus += async (status, message) =>
{
if (_connection is null || !_connection.IsConnected)
return;
await _connection.UpdateStatusAsync(status, message);
_currentStatus = status;
_currentStatusMessage = message;
};
_commandHandler.OnSetNick += async (displayName) =>
{
if (_apiClient is null)
return;
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName));
_app.Invoke(() =>
{
_mainWindow!.SetCurrentUser(displayName);
_mainWindow.UpdateStatusBar("Connected");
});
};
_commandHandler.OnSetColor += async (color) =>
{
if (_apiClient is null)
return;
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color));
};
_commandHandler.OnSetTheme += (name) =>
{
_app.Invoke(() => HandleThemeSelected(name));
return Task.CompletedTask;
};
_commandHandler.OnSendFile += async (target) =>
{
if (_apiClient is null || _connection is null || !_connection.IsConnected)
return;
var channel = _mainWindow!.CurrentChannel;
if (string.IsNullOrEmpty(channel))
return;
try
{
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
// Download from URL then upload
Log.Information("Downloading file from {Url}", target);
using var httpClient = new HttpClient();
using var response = await httpClient.GetAsync(uri);
response.EnsureSuccessStatusCode();
var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
{
// Try to infer extension from Content-Type
var contentType = response.Content.Headers.ContentType?.MediaType ?? "";
var ext = contentType switch
{
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
_ => ""
};
fileName = $"download{ext}";
}
await using var stream = await response.Content.ReadAsStreamAsync();
await _apiClient.UploadFileAsync(channel, stream, fileName);
Log.Information("URL file uploaded: {FileName}", fileName);
}
else
{
// Local file
await using var stream = File.OpenRead(target);
var fileName = Path.GetFileName(target);
await _apiClient.UploadFileAsync(channel, stream, fileName);
}
}
catch (Exception ex)
{
Log.Error(ex, "File upload failed for {Target}", target);
_app.Invoke(() =>
_mainWindow!.ShowError($"File upload failed: {ex.Message}"));
}
};
_commandHandler.OnOpenProfile += () =>
{
_app.Invoke(HandleProfileRequested);
return Task.CompletedTask;
};
_commandHandler.OnOpenServers += () =>
{
_app.Invoke(HandleSavedServersRequested);
return Task.CompletedTask;
};
_commandHandler.OnJoinChannel += async (channelName) =>
{
if (_connection is null || !_connection.IsConnected)
return;
try
{
var history = await _connection.JoinChannelAsync(channelName);
_app.Invoke(() =>
{
_mainWindow!.SwitchToChannel(channelName);
if (history.Count > 0)
_mainWindow.LoadHistory(channelName, history);
});
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Failed to join channel: {ex.Message}"));
}
};
_commandHandler.OnLeaveChannel += async () =>
{
if (_connection is null || !_connection.IsConnected)
return;
var channel = _mainWindow!.CurrentChannel;
if (string.IsNullOrEmpty(channel))
return;
try
{
await _connection.LeaveChannelAsync(channel);
_app.Invoke(() =>
_mainWindow!.AddSystemMessage(channel, $"You left #{channel}"));
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Failed to leave channel: {ex.Message}"));
}
};
_commandHandler.OnSetTopic += async (topic) =>
{
// Topic setting would go through an API endpoint if available.
// For now, show as a system message.
await Task.CompletedTask;
_app.Invoke(() =>
{
var channel = _mainWindow!.CurrentChannel;
if (!string.IsNullOrEmpty(channel))
_mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}");
});
};
_commandHandler.OnListUsers += async () =>
{
if (_connection is null || !_connection.IsConnected)
return;
var channel = _mainWindow!.CurrentChannel;
if (string.IsNullOrEmpty(channel))
return;
try
{
var users = await _connection.GetOnlineUsersAsync(channel);
_app.Invoke(() =>
{
_mainWindow!.AddSystemMessage(channel, $"Online users in #{channel}:");
foreach (var user in users)
{
var displayName = user.DisplayName ?? user.Username;
var statusText = user.Status.ToString();
if (!string.IsNullOrWhiteSpace(user.StatusMessage))
statusText += $" - {user.StatusMessage}";
_mainWindow.AddSystemMessage(channel, $" {displayName} ({statusText})");
}
});
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Failed to list users: {ex.Message}"));
}
};
_commandHandler.OnQuit += () =>
{
_app.Invoke(() => _app.RequestStop());
return Task.CompletedTask;
};
// OnHelp is handled by CommandHandler returning help text — no additional wiring needed.
}
// ── MainWindow Event Handlers ──────────────────────────────────────────
private static void HandleConnect()
{
var result = ConnectDialog.Show(_app, _config.SavedServers);
if (result is null)
return;
Log.Information("Connecting to {Url} as {User} (register={IsRegister})",
result.ServerUrl, result.Username, result.IsRegister);
Task.Run(async () =>
{
try
{
_apiClient?.Dispose();
_apiClient = new ApiClient(result.ServerUrl);
_app.Invoke(() =>
_mainWindow!.UpdateStatusBar("Authenticating..."));
LoginResponse loginResponse;
if (result.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
}
_currentUsername = loginResponse.Username;
_app.Invoke(() =>
{
_mainWindow!.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
_mainWindow.UpdateStatusBar("Authenticated, connecting...");
});
// Dispose previous connection if any
if (_connection is not null)
{
await _connection.DisposeAsync();
}
_connection = new EchoHubConnection(result.ServerUrl, _apiClient.Token!);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
// Load channels
var channels = await _apiClient.GetChannelsAsync();
_app.Invoke(() =>
{
_mainWindow!.SetChannels(channels);
_mainWindow.UpdateStatusBar("Connected");
});
// Join the default channel
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
_app.Invoke(() =>
_mainWindow!.SwitchToChannel(HubConstants.DefaultChannel));
// Load history for default channel
try
{
var history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
_app.Invoke(() =>
{
_mainWindow!.LoadHistory(HubConstants.DefaultChannel, history);
_mainWindow.FocusInput();
});
}
catch
{
// History might not be available, that is okay
}
// Save server to config on successful connection
var savedServer = new SavedServer
{
Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl,
Username = result.Username,
Token = _apiClient.Token,
LastConnected = DateTimeOffset.Now
};
ConfigManager.SaveServer(savedServer);
_config = ConfigManager.Load();
Log.Information("Connected successfully to {Url}", result.ServerUrl);
}
catch (Exception ex)
{
Log.Error(ex, "Connection failed to {Url}", result.ServerUrl);
_app.Invoke(() =>
{
_mainWindow!.ShowError($"Connection failed: {ex.Message}");
_mainWindow.UpdateStatusBar("Disconnected");
});
}
});
}
private static void HandleDisconnect()
{
Log.Information("Disconnecting from server");
Task.Run(async () =>
{
try
{
if (_connection is not null)
{
await _connection.DisconnectAsync();
await _connection.DisposeAsync();
_connection = null;
}
_apiClient?.Dispose();
_apiClient = null;
_app.Invoke(() =>
{
_mainWindow!.ClearAll();
_mainWindow.UpdateStatusBar("Disconnected");
});
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Disconnect error: {ex.Message}"));
}
});
}
private static void HandleMessageSubmitted(string channelName, string content)
{
if (_connection is null || !_connection.IsConnected)
{
_mainWindow!.ShowError("Not connected to a server.");
return;
}
// Check if this is a command
if (_commandHandler!.IsCommand(content))
{
Task.Run(async () =>
{
try
{
var result = await _commandHandler.HandleAsync(content);
if (result.Message is not null)
{
_app.Invoke(() =>
{
if (result.IsError)
_mainWindow!.ShowError(result.Message);
else
_mainWindow!.AddSystemMessage(channelName, result.Message);
});
}
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Command failed: {ex.Message}"));
}
});
return;
}
// Regular message
Task.Run(async () =>
{
try
{
await _connection.SendMessageAsync(channelName, content);
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Send failed: {ex.Message}"));
}
});
}
private static void HandleChannelSelected(string channelName)
{
if (_connection is null || !_connection.IsConnected)
return;
Task.Run(async () =>
{
try
{
await _connection.JoinChannelAsync(channelName);
// Load history if the channel has no messages cached yet
try
{
var history = await _connection.GetHistoryAsync(channelName);
_app.Invoke(() =>
_mainWindow!.LoadHistory(channelName, history));
}
catch
{
// History might not be available
}
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Failed to join channel: {ex.Message}"));
}
});
}
private static void HandleProfileRequested()
{
Task.Run(async () =>
{
UserProfileDto? profile = null;
try
{
if (_apiClient is not null && !string.IsNullOrEmpty(_currentUsername))
{
profile = await _apiClient.GetUserProfileAsync(_currentUsername);
}
}
catch
{
// Profile may not be available; continue with null
}
_app.Invoke(() =>
{
var action = UserPanelDialog.Show(_app,
profile,
_config.SavedServers,
_currentStatus,
_currentStatusMessage);
switch (action)
{
case UserPanelAction.EditProfile:
HandleEditProfile(profile);
break;
case UserPanelAction.SetStatus:
HandleStatusRequested();
break;
case UserPanelAction.Close:
default:
break;
}
});
});
}
private static void HandleEditProfile(UserProfileDto? currentProfile)
{
var editResult = ProfileEditDialog.Show(_app,
currentProfile?.DisplayName,
currentProfile?.Bio,
currentProfile?.NicknameColor);
if (editResult is null)
return;
Task.Run(async () =>
{
try
{
if (_apiClient is not null)
{
await _apiClient.UpdateProfileAsync(new UpdateProfileRequest(
editResult.DisplayName,
editResult.Bio,
editResult.NicknameColor));
if (editResult.DisplayName is not null)
{
_app.Invoke(() =>
{
_mainWindow!.SetCurrentUser(editResult.DisplayName);
_mainWindow.UpdateStatusBar("Connected");
});
}
// Update local config preset
_config.DefaultPreset = new AccountPreset
{
DisplayName = editResult.DisplayName,
Bio = editResult.Bio,
NicknameColor = editResult.NicknameColor
};
ConfigManager.Save(_config);
}
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Profile update failed: {ex.Message}"));
}
});
}
private static void HandleStatusRequested()
{
var result = StatusDialog.Show(_app, _currentStatus, _currentStatusMessage);
if (result is null)
return;
_currentStatus = result.Status;
_currentStatusMessage = result.StatusMessage;
if (_connection is not null && _connection.IsConnected)
{
Task.Run(async () =>
{
try
{
await _connection.UpdateStatusAsync(result.Status, result.StatusMessage);
}
catch (Exception ex)
{
_app.Invoke(() =>
_mainWindow!.ShowError($"Status update failed: {ex.Message}"));
}
});
}
}
private static void HandleThemeSelected(string themeName)
{
Log.Information("Theme selected: {Theme}", themeName);
var theme = Themes.ThemeManager.GetTheme(themeName);
Themes.ThemeManager.ApplyTheme(theme);
_config.ActiveTheme = themeName;
ConfigManager.Save(_config);
// Defer UI refresh so the menu finishes processing its click first
_app.Invoke(() =>
{
_mainWindow!.ApplyColorSchemes();
_mainWindow.SetNeedsDraw();
Log.Debug("Theme applied and UI refreshed");
});
}
private static void HandleSavedServersRequested()
{
if (_config.SavedServers.Count == 0)
{
MessageBox.Query(_app, "Saved Servers", "No saved servers yet.\nConnect to a server to save it automatically.", "OK");
return;
}
var serverLines = _config.SavedServers
.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}")
.ToList();
var message = string.Join("\n", serverLines);
MessageBox.Query(_app, "Saved Servers", message, "OK");
}
// ── Connection Event Wiring ────────────────────────────────────────────
private static void WireConnectionEvents(EchoHubConnection connection)
{
connection.OnMessageReceived += message =>
{
_app.Invoke(() =>
_mainWindow!.AddMessage(message));
};
connection.OnUserJoined += (channelName, username) =>
{
_app.Invoke(() =>
_mainWindow!.AddSystemMessage(channelName, $"{username} joined the channel"));
};
connection.OnUserLeft += (channelName, username) =>
{
_app.Invoke(() =>
_mainWindow!.AddSystemMessage(channelName, $"{username} left the channel"));
};
connection.OnUserStatusChanged += presence =>
{
_app.Invoke(() =>
{
var displayName = presence.DisplayName ?? presence.Username;
var statusText = presence.Status.ToString();
if (!string.IsNullOrWhiteSpace(presence.StatusMessage))
statusText += $" - {presence.StatusMessage}";
// Show status change in all active channels
foreach (var channelName in _mainWindow!.GetChannelNames())
{
_mainWindow.AddStatusMessage(channelName, displayName, statusText);
}
});
};
connection.OnError += errorMessage =>
{
_app.Invoke(() =>
_mainWindow!.ShowError(errorMessage));
};
connection.OnConnectionStateChanged += status =>
{
_app.Invoke(() =>
_mainWindow!.UpdateStatusBar(status));
};
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using EchoHub.Core.DTOs;
namespace EchoHub.Client.Services;
public sealed class ApiClient : IDisposable
{
private readonly HttpClient _http;
private string? _token;
public string? Token => _token;
public string BaseUrl { get; }
public ApiClient(string baseUrl)
{
BaseUrl = baseUrl.TrimEnd('/');
_http = new HttpClient
{
BaseAddress = new Uri(BaseUrl)
};
}
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
{
var request = new RegisterRequest(username, password, displayName);
var response = await _http.PostAsJsonAsync("/api/auth/register", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
?? throw new InvalidOperationException("Registration returned empty response.");
SetToken(result.Token);
return result;
}
public async Task<LoginResponse> LoginAsync(string username, string password)
{
var request = new LoginRequest(username, password);
var response = await _http.PostAsJsonAsync("/api/auth/login", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
?? throw new InvalidOperationException("Login returned empty response.");
SetToken(result.Token);
return result;
}
public async Task<List<ChannelDto>> GetChannelsAsync()
{
EnsureAuthenticated();
var channels = await _http.GetFromJsonAsync<List<ChannelDto>>("/api/channels");
return channels ?? [];
}
public async Task<ServerStatusDto?> GetServerInfoAsync()
{
var info = await _http.GetFromJsonAsync<ServerStatusDto>("/api/server/info");
return info;
}
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
{
EnsureAuthenticated();
var profile = await _http.GetFromJsonAsync<UserProfileDto>($"/api/users/{Uri.EscapeDataString(username)}/profile");
return profile;
}
public async Task<UserProfileDto?> UpdateProfileAsync(UpdateProfileRequest request)
{
EnsureAuthenticated();
var response = await _http.PutAsJsonAsync("/api/users/profile", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<UserProfileDto>();
}
public async Task<string?> UploadAvatarAsync(Stream imageStream, string fileName)
{
EnsureAuthenticated();
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var response = await _http.PostAsync("/api/users/avatar", content);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AvatarUploadResponse>();
return result?.AsciiArt;
}
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName)
{
EnsureAuthenticated();
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
var response = await _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<MessageDto>();
}
private void SetToken(string token)
{
_token = token;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode)
return;
// Try to extract a meaningful error message from the response body
var errorMessage = $"{(int)response.StatusCode} {response.ReasonPhrase}";
try
{
var body = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(body))
{
// Try to parse {"error": "..."} format
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var errorProp) ||
doc.RootElement.TryGetProperty("Error", out errorProp))
{
errorMessage = errorProp.GetString() ?? errorMessage;
}
else
{
errorMessage = body;
}
}
}
catch
{
// If we can't parse the body, use the status code message
}
throw new HttpRequestException(errorMessage);
}
private void EnsureAuthenticated()
{
if (string.IsNullOrEmpty(_token))
throw new InvalidOperationException("Not authenticated. Call LoginAsync or RegisterAsync first.");
}
private static string GetContentType(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
".txt" => "text/plain",
".pdf" => "application/pdf",
_ => "application/octet-stream",
};
}
public void Dispose()
{
_http.Dispose();
}
}
internal record AvatarUploadResponse(string AsciiArt);
@@ -0,0 +1,135 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Client.Services;
public sealed class EchoHubConnection : IAsyncDisposable
{
private readonly HubConnection _connection;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
public event Action<string, string>? OnUserLeft;
public event Action<ChannelDto>? OnChannelUpdated;
public event Action<UserPresenceDto>? OnUserStatusChanged;
public event Action<string>? OnError;
public event Action<string>? OnConnectionStateChanged;
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, string jwtToken)
{
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(jwtToken);
})
.WithAutomaticReconnect()
.Build();
RegisterHandlers();
_connection.Reconnecting += _ =>
{
OnConnectionStateChanged?.Invoke("Reconnecting...");
return Task.CompletedTask;
};
_connection.Reconnected += _ =>
{
OnConnectionStateChanged?.Invoke("Connected");
return Task.CompletedTask;
};
_connection.Closed += _ =>
{
OnConnectionStateChanged?.Invoke("Disconnected");
return Task.CompletedTask;
};
}
private void RegisterHandlers()
{
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
OnMessageReceived?.Invoke(message);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
{
OnUserJoined?.Invoke(channelName, username);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
{
OnUserLeft?.Invoke(channelName, username);
});
_connection.On<ChannelDto>(nameof(Core.Contracts.IEchoHubClient.ChannelUpdated), channel =>
{
OnChannelUpdated?.Invoke(channel);
});
_connection.On<UserPresenceDto>(nameof(Core.Contracts.IEchoHubClient.UserStatusChanged), presence =>
{
OnUserStatusChanged?.Invoke(presence);
});
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.Error), message =>
{
OnError?.Invoke(message);
});
}
public async Task ConnectAsync()
{
OnConnectionStateChanged?.Invoke("Connecting...");
await _connection.StartAsync();
OnConnectionStateChanged?.Invoke("Connected");
}
public async Task DisconnectAsync()
{
await _connection.StopAsync();
OnConnectionStateChanged?.Invoke("Disconnected");
}
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
{
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
}
public async Task LeaveChannelAsync(string channelName)
{
await _connection.InvokeAsync("LeaveChannel", channelName);
}
public async Task SendMessageAsync(string channelName, string content)
{
await _connection.InvokeAsync("SendMessage", channelName, content);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
{
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
}
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
{
await _connection.InvokeAsync("UpdateStatus", status, statusMessage);
}
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
{
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace EchoHub.Client.Themes;
public class Theme
{
public required string Name { get; set; }
public ThemeColors Base { get; set; } = new();
public ThemeColors Menu { get; set; } = new();
public ThemeColors Dialog { get; set; } = new();
public ThemeColors Status { get; set; } = new();
}
public class ThemeColors
{
public string Foreground { get; set; } = "White";
public string Background { get; set; } = "Black";
public string FocusForeground { get; set; } = "White";
public string FocusBackground { get; set; } = "Blue";
}
+317
View File
@@ -0,0 +1,317 @@
using System.Text.Json;
using Terminal.Gui.Drawing;
using Terminal.Gui.Configuration;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.Themes;
public static class ThemeManager
{
private static readonly string ThemeDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "themes");
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private static readonly Theme DefaultTheme = new()
{
Name = "Default",
Base = new ThemeColors
{
Foreground = "Gray",
Background = "Black",
FocusForeground = "White",
FocusBackground = "DarkGray"
},
Menu = new ThemeColors
{
Foreground = "Gray",
Background = "Black",
FocusForeground = "White",
FocusBackground = "DarkGray"
},
Dialog = new ThemeColors
{
Foreground = "White",
Background = "DarkGray",
FocusForeground = "Black",
FocusBackground = "Gray"
},
Status = new ThemeColors
{
Foreground = "White",
Background = "DarkGray",
FocusForeground = "White",
FocusBackground = "DarkGray"
}
};
private static readonly Theme ClassicTheme = new()
{
Name = "Classic",
Base = new ThemeColors
{
Foreground = "White",
Background = "Blue",
FocusForeground = "Black",
FocusBackground = "Cyan"
},
Menu = new ThemeColors
{
Foreground = "White",
Background = "DarkGray",
FocusForeground = "White",
FocusBackground = "Black"
},
Dialog = new ThemeColors
{
Foreground = "White",
Background = "DarkGray",
FocusForeground = "Black",
FocusBackground = "Cyan"
},
Status = new ThemeColors
{
Foreground = "White",
Background = "DarkGray",
FocusForeground = "White",
FocusBackground = "DarkGray"
}
};
private static readonly Theme LightTheme = new()
{
Name = "Light",
Base = new ThemeColors
{
Foreground = "Black",
Background = "White",
FocusForeground = "White",
FocusBackground = "Blue"
},
Menu = new ThemeColors
{
Foreground = "White",
Background = "Blue",
FocusForeground = "BrightYellow",
FocusBackground = "Blue"
},
Dialog = new ThemeColors
{
Foreground = "Black",
Background = "White",
FocusForeground = "White",
FocusBackground = "Blue"
},
Status = new ThemeColors
{
Foreground = "White",
Background = "Blue",
FocusForeground = "White",
FocusBackground = "Blue"
}
};
private static readonly Theme HackerTheme = new()
{
Name = "Hacker",
Base = new ThemeColors
{
Foreground = "BrightGreen",
Background = "Black",
FocusForeground = "Black",
FocusBackground = "Green"
},
Menu = new ThemeColors
{
Foreground = "BrightGreen",
Background = "Black",
FocusForeground = "Black",
FocusBackground = "BrightGreen"
},
Dialog = new ThemeColors
{
Foreground = "BrightGreen",
Background = "Black",
FocusForeground = "Black",
FocusBackground = "Green"
},
Status = new ThemeColors
{
Foreground = "BrightGreen",
Background = "Black",
FocusForeground = "BrightGreen",
FocusBackground = "Black"
}
};
private static readonly Theme SolarizedTheme = new()
{
Name = "Solarized",
Base = new ThemeColors
{
Foreground = "Cyan",
Background = "Black",
FocusForeground = "BrightYellow",
FocusBackground = "DarkGray"
},
Menu = new ThemeColors
{
Foreground = "BrightCyan",
Background = "DarkGray",
FocusForeground = "BrightYellow",
FocusBackground = "Black"
},
Dialog = new ThemeColors
{
Foreground = "Cyan",
Background = "DarkGray",
FocusForeground = "BrightYellow",
FocusBackground = "Black"
},
Status = new ThemeColors
{
Foreground = "BrightCyan",
Background = "DarkGray",
FocusForeground = "BrightCyan",
FocusBackground = "DarkGray"
}
};
private static readonly Theme TransparentTheme = new()
{
Name = "Transparent",
Base = new ThemeColors
{
Foreground = "White",
Background = "Black",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Menu = new ThemeColors
{
Foreground = "White",
Background = "Black",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Dialog = new ThemeColors
{
Foreground = "White",
Background = "Black",
FocusForeground = "BrightCyan",
FocusBackground = "Black"
},
Status = new ThemeColors
{
Foreground = "Gray",
Background = "Black",
FocusForeground = "Gray",
FocusBackground = "Black"
}
};
private static readonly List<Theme> BuiltInThemes =
[
DefaultTheme,
TransparentTheme,
ClassicTheme,
LightTheme,
HackerTheme,
SolarizedTheme
];
public static List<Theme> GetAvailableThemes()
{
var themes = new List<Theme>(BuiltInThemes);
try
{
if (Directory.Exists(ThemeDir))
{
foreach (var file in Directory.GetFiles(ThemeDir, "*.json"))
{
try
{
var json = File.ReadAllText(file);
var theme = JsonSerializer.Deserialize<Theme>(json, JsonOptions);
if (theme is not null && !string.IsNullOrWhiteSpace(theme.Name))
{
// Skip if a built-in theme already has this name
if (!themes.Exists(t => string.Equals(t.Name, theme.Name, StringComparison.OrdinalIgnoreCase)))
{
themes.Add(theme);
}
}
}
catch
{
// Skip malformed theme files
}
}
}
}
catch
{
// If we can't read the theme directory, just return built-ins
}
return themes;
}
public static Theme GetTheme(string name)
{
var themes = GetAvailableThemes();
return themes.Find(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase))
?? DefaultTheme;
}
public static void ApplyTheme(Theme theme)
{
SchemeManager.AddScheme("Base", BuildColorScheme(theme.Base));
SchemeManager.AddScheme("Menu", BuildColorScheme(theme.Menu));
SchemeManager.AddScheme("Dialog", BuildColorScheme(theme.Dialog));
}
public static void SaveTheme(Theme theme)
{
try
{
Directory.CreateDirectory(ThemeDir);
var fileName = $"{theme.Name}.json";
var filePath = Path.Combine(ThemeDir, fileName);
var json = JsonSerializer.Serialize(theme, JsonOptions);
File.WriteAllText(filePath, json);
}
catch
{
// Silently fail — theme save is best-effort
}
}
private static Scheme BuildColorScheme(ThemeColors colors)
{
var normal = new Attribute(ParseColor(colors.Foreground), ParseColor(colors.Background));
var focus = new Attribute(ParseColor(colors.FocusForeground), ParseColor(colors.FocusBackground));
return new Scheme
{
Normal = normal,
Focus = focus,
HotNormal = normal,
HotFocus = focus,
Disabled = normal
};
}
private static Color ParseColor(string colorName)
{
if (Color.TryParse(colorName, out var color))
return color ?? Color.White;
return Color.White;
}
}
+228
View File
@@ -0,0 +1,228 @@
using System.Collections.ObjectModel;
using EchoHub.Client.Config;
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the connect dialog.
/// </summary>
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
/// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details.
/// Includes a saved servers selector when saved servers are available.
/// </summary>
public sealed class ConnectDialog
{
/// <summary>
/// Shows the connect dialog with an optional list of saved servers.
/// Returns the result, or null if cancelled.
/// </summary>
public static ConnectDialogResult? Show(IApplication app, List<SavedServer>? savedServers = null)
{
ConnectDialogResult? result = null;
savedServers ??= [];
var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 20 : 16;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0;
// -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null;
if (hasSavedServers)
{
var savedLabel = new Label
{
Text = "Saved Servers:",
X = 1,
Y = 1
};
dialog.Add(savedLabel);
var serverDisplayNames = savedServers
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
.ToList();
savedServerList = new ListView
{
Source = new ListWrapper<string>(new ObservableCollection<string>(serverDisplayNames)),
X = 1,
Y = 2,
Width = Dim.Fill(2),
Height = 3
};
dialog.Add(savedServerList);
// Visual separator
var separator = new Label
{
Text = new string('-', 56),
X = 1,
Y = 5
};
dialog.Add(separator);
yOffset = 5;
}
// -- Manual entry fields ----------------------------------------------
var urlLabel = new Label
{
Text = "Server URL:",
X = 1,
Y = yOffset + 1
};
var urlField = new TextField
{
Text = "http://localhost:5000",
X = 15,
Y = yOffset + 1,
Width = Dim.Fill(2)
};
var userLabel = new Label
{
Text = "Username:",
X = 1,
Y = yOffset + 3
};
var userField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 3,
Width = Dim.Fill(2)
};
var passLabel = new Label
{
Text = "Password:",
X = 1,
Y = yOffset + 5
};
var passField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 5,
Width = Dim.Fill(2),
Secret = true
};
var displayLabel = new Label
{
Text = "Display Name:",
X = 1,
Y = yOffset + 7
};
var displayField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 7,
Width = Dim.Fill(2)
};
var loginButton = new Button
{
Text = "Login",
IsDefault = true,
X = Pos.Center() - 20,
Y = yOffset + 9
};
var registerButton = new Button
{
Text = "Register",
X = Pos.Center() - 5,
Y = yOffset + 9
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 10,
Y = yOffset + 9
};
// Wire saved server selection to auto-fill fields
if (savedServerList is not null && savedServers.Count > 0)
{
savedServerList.ValueChanged += (sender, e) =>
{
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{
var server = savedServers[index.Value];
urlField.Text = server.Url;
userField.Text = server.Username ?? "";
}
};
// Pre-fill with the first saved server
urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? "";
}
loginButton.Accepting += (s, e) =>
{
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
e.Handled = true;
app.RequestStop();
};
registerButton.Accepting += (s, e) =>
{
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
displayLabel, displayField, loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus();
else
urlField.SetFocus();
app.Run(dialog);
return result;
}
}
+511
View File
@@ -0,0 +1,511 @@
using System.Collections.ObjectModel;
using EchoHub.Client.Themes;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using Terminal.Gui.Configuration;
using Terminal.Gui.Input;
namespace EchoHub.Client.UI;
/// <summary>
/// Main Terminal.Gui window for the EchoHub chat client.
/// </summary>
public sealed class MainWindow : Window
{
private readonly IApplication _app;
private readonly ListView _channelList;
private readonly ListView _messageList;
private readonly TextView _inputField;
private readonly FrameView _chatFrame;
private readonly Label _statusLabel;
private MenuBar _menuBar;
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<string>> _channelMessages = [];
private string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
/// <summary>
/// Fired when the user selects a channel. Parameter is the channel name.
/// </summary>
public event Action<string>? OnChannelSelected;
/// <summary>
/// Fired when the user presses Enter in the input field. Parameters: channel name, message content.
/// </summary>
public event Action<string, string>? OnMessageSubmitted;
/// <summary>
/// Fired when the user requests to connect via the menu.
/// </summary>
public event Action? OnConnectRequested;
/// <summary>
/// Fired when the user requests to disconnect via the menu.
/// </summary>
public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to open their profile panel.
/// </summary>
public event Action? OnProfileRequested;
/// <summary>
/// Fired when the user requests to set their status.
/// </summary>
public event Action? OnStatusRequested;
/// <summary>
/// Fired when the user selects a theme from the menu. Parameter is the theme name.
/// </summary>
public event Action<string>? OnThemeSelected;
/// <summary>
/// Fired when the user requests to view saved servers.
/// </summary>
public event Action? OnSavedServersRequested;
public MainWindow(IApplication app)
{
_app = app;
Title = "EchoHub";
BorderStyle = LineStyle.None;
// Menu bar at the top
_menuBar = BuildMenuBar();
Add(_menuBar);
// Left panel - channels
var channelsFrame = new FrameView
{
Title = "Channels",
X = 0,
Y = 1, // below menu bar
Width = 25,
Height = Dim.Fill(1) // leave room for status bar
};
_channelList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_channelList.ValueChanged += OnChannelListSelectionChanged;
channelsFrame.Add(_channelList);
Add(channelsFrame);
// Center panel - messages
_chatFrame = new FrameView
{
Title = "Chat",
X = 25,
Y = 1, // below menu bar
Width = Dim.Fill(),
Height = Dim.Fill(6) // leave room for input area and status bar
};
_messageList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
_chatFrame.Add(_messageList);
Add(_chatFrame);
// Bottom input area (multiline)
var inputFrame = new FrameView
{
Title = "Message (Enter=send, Shift+Enter=newline)",
X = 25,
Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(),
Height = 5
};
_inputField = new TextView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
Text = "",
WordWrap = true
};
_inputField.KeyDown += OnInputKeyDown;
inputFrame.Add(_inputField);
Add(inputFrame);
// Status bar at the very bottom
_statusLabel = new Label
{
Text = "Disconnected",
X = 0,
Y = Pos.AnchorEnd(1),
Width = Dim.Fill(),
Height = 1
};
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
Add(_statusLabel);
// Apply our custom color schemes to all views
ApplyColorSchemes();
}
/// <summary>
/// Applies the currently registered color schemes to all views.
/// Call after theme changes to refresh colors.
/// </summary>
public void ApplyColorSchemes()
{
var baseScheme = SchemeManager.GetScheme("Base");
var menuScheme = SchemeManager.GetScheme("Menu");
if (baseScheme is not null)
{
this.SetScheme(baseScheme);
// Propagate to all child views that should use the base scheme
foreach (var sub in SubViews)
{
if (sub != _menuBar && sub != _statusLabel)
sub.SetScheme(baseScheme);
}
}
if (menuScheme is not null)
{
_menuBar.SetScheme(menuScheme);
_statusLabel.SetScheme(menuScheme);
}
}
/// <summary>
/// Builds the menu bar with File, Server, User menus and a theme submenu.
/// </summary>
private MenuBar BuildMenuBar()
{
// Build theme menu items and prepend them with a separator header
var themeItems = new List<MenuItem>();
foreach (var t in Themes.ThemeManager.GetAvailableThemes())
{
var name = t.Name;
themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty));
}
// Combine user items with theme items, separated by a line
var userMenuChildren = new MenuItem[]
{
new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty),
new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty),
};
// Merge: user items + separator + theme items
var allUserItems = new MenuItem[userMenuChildren.Length + 1 + themeItems.Count];
userMenuChildren.CopyTo(allUserItems, 0);
allUserItems[userMenuChildren.Length] = null!; // null separator
for (int i = 0; i < themeItems.Count; i++)
allUserItems[userMenuChildren.Length + 1 + i] = themeItems[i];
return new MenuBar(
[
new MenuBarItem("_File",
[
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
]),
new MenuBarItem("_Server",
[
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
null!,
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
]),
new MenuBarItem("_User", allUserItems)
]);
}
/// <summary>
/// Rebuilds and replaces the menu bar (e.g., after theme list changes).
/// </summary>
public void RefreshMenuBar()
{
Remove(_menuBar);
_menuBar = BuildMenuBar();
Add(_menuBar);
ApplyColorSchemes();
SetNeedsDraw();
}
private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs<int?> e)
{
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
{
var channelName = _channelNames[index.Value];
if (channelName != _currentChannel)
{
SwitchToChannel(channelName);
OnChannelSelected?.Invoke(channelName);
}
}
}
private void OnInputKeyDown(object? sender, Key e)
{
if (e == Key.Enter.WithShift)
{
// Shift+Enter: let TextView handle it (inserts newline)
return;
}
if (e == Key.Enter)
{
var text = _inputField.Text?.Trim() ?? string.Empty;
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
{
OnMessageSubmitted?.Invoke(_currentChannel, text);
_inputField.Text = string.Empty;
}
e.Handled = true;
}
}
/// <summary>
/// Add a message to the specified channel's message list and refresh if it is the current channel.
/// </summary>
public void AddMessage(MessageDto message)
{
var lines = FormatMessage(message);
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
{
messages = [];
_channelMessages[message.ChannelName] = messages;
}
foreach (var line in lines)
{
messages.Add(line);
}
if (message.ChannelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Add a system/informational message to a channel.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}";
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(formatted);
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Add a status change message to a channel.
/// </summary>
public void AddStatusMessage(string channelName, string username, string status)
{
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}";
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(formatted);
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Set the list of available channels and refresh the channel list view.
/// </summary>
public void SetChannels(List<ChannelDto> channels)
{
_channelNames.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = [];
}
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
}
/// <summary>
/// Show an error message to the user.
/// </summary>
public void ShowError(string message)
{
MessageBox.ErrorQuery(_app, "Error", message, "OK");
}
/// <summary>
/// Update the connection status displayed in the status bar.
/// </summary>
public void UpdateStatusBar(string status)
{
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
_statusLabel.Text = $" {status}{userPart}{channelPart}";
}
/// <summary>
/// Set the current user name for display in the status bar.
/// </summary>
public void SetCurrentUser(string username)
{
_currentUser = username;
}
/// <summary>
/// Get the current channel name.
/// </summary>
public string CurrentChannel => _currentChannel;
/// <summary>
/// Get all channel names that have message buffers (for broadcasting status changes).
/// </summary>
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
/// <summary>
/// Switch the chat view to the given channel.
/// </summary>
public void SwitchToChannel(string channelName)
{
_currentChannel = channelName;
_chatFrame.Title = $"#{channelName}";
RefreshMessages();
// Update channel list selection
var idx = _channelNames.IndexOf(channelName);
if (idx >= 0)
_channelList.SelectedItem = idx;
}
/// <summary>
/// Load historical messages into a channel (prepend).
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages)
{
if (!_channelMessages.TryGetValue(channelName, out var existing))
{
existing = [];
_channelMessages[channelName] = existing;
}
var formatted = messages.SelectMany(FormatMessage).ToList();
existing.InsertRange(0, formatted);
if (channelName == _currentChannel)
{
RefreshMessages();
}
}
/// <summary>
/// Clear all messages and channels (used on disconnect).
/// </summary>
public void ClearAll()
{
_channelNames.Clear();
_channelMessages.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_chatFrame.Title = "Chat";
RefreshMessages();
}
/// <summary>
/// Focus the input field for typing.
/// </summary>
public void FocusInput()
{
_inputField.SetFocus();
}
private void RefreshMessages()
{
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
{
_messageList.SetSource(new ObservableCollection<string>(messages));
if (messages.Count > 0)
_messageList.SelectedItem = messages.Count - 1;
}
else
{
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
}
}
/// <summary>
/// Format a message DTO into one or more display lines based on its MessageType.
/// </summary>
private static List<string> FormatMessage(MessageDto message)
{
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var sender = message.SenderNicknameColor is not null
? $"<{message.SenderUsername}>"
: message.SenderUsername + ":";
var lines = new List<string>();
switch (message.Type)
{
case MessageType.Image:
lines.Add($"[{time}] {sender} [Image]");
// Content IS the ASCII art — add each line as a separate list item
if (!string.IsNullOrWhiteSpace(message.Content))
{
foreach (var artLine in message.Content.Split('\n'))
{
lines.Add($" {artLine}");
}
}
break;
case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown";
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
lines.Add($"[{time}] {sender} [File: {fileName}]{fileContent}");
break;
case MessageType.Text:
default:
var contentLines = message.Content.Split('\n');
lines.Add($"[{time}] {sender} {contentLines[0]}");
// Continuation lines indented to align with first line's content
for (int i = 1; i < contentLines.Length; i++)
{
lines.Add($" {contentLines[i]}");
}
break;
}
return lines;
}
}
+190
View File
@@ -0,0 +1,190 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the profile edit dialog.
/// </summary>
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
/// <summary>
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
/// </summary>
public sealed class ProfileEditDialog
{
/// <summary>
/// Shows the profile edit dialog and returns the result, or null if cancelled.
/// </summary>
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
{
ProfileEditResult? result = null;
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
// Display Name
var nameLabel = new Label
{
Text = "Display Name:",
X = 1,
Y = 1
};
var nameField = new TextField
{
Text = currentDisplayName ?? "",
X = 17,
Y = 1,
Width = Dim.Fill(2)
};
// Bio
var bioLabel = new Label
{
Text = "Bio:",
X = 1,
Y = 3
};
var bioField = new TextField
{
Text = currentBio ?? "",
X = 17,
Y = 3,
Width = Dim.Fill(2)
};
// Nickname Color
var colorLabel = new Label
{
Text = "Nickname Color:",
X = 1,
Y = 5
};
var colorField = new TextField
{
Text = currentColor ?? "",
X = 17,
Y = 5,
Width = Dim.Fill(2)
};
var colorHintLabel = new Label
{
Text = "(hex e.g. #FF5733)",
X = 17,
Y = 6
};
colorHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
// Color Preview
var previewLabel = new Label
{
Text = "Preview:",
X = 1,
Y = 8
};
var colorPreview = new Label
{
Text = "\u2588\u2588\u2588\u2588\u2588\u2588",
X = 17,
Y = 8
};
UpdateColorPreview(colorPreview, colorField.Text);
colorField.TextChanged += (sender, e) =>
{
UpdateColorPreview(colorPreview, colorField.Text);
};
// Buttons
var saveButton = new Button
{
Text = "Save",
IsDefault = true,
X = Pos.Center() - 10,
Y = 10
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 10
};
saveButton.Accepting += (s, e) =>
{
var displayName = NullIfEmpty(nameField.Text?.Trim());
var bio = NullIfEmpty(bioField.Text?.Trim());
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
result = new ProfileEditResult(displayName, bio, nicknameColor);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
/// <summary>
/// Attempts to parse a hex color string and update the preview label color.
/// </summary>
private static void UpdateColorPreview(Label preview, string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
{
preview.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.Blue)
});
return;
}
var color = ParseHexToTrueColor(hexColor.Trim());
preview.SetScheme(new Scheme
{
Normal = new Attribute(color, Color.Blue)
});
}
/// <summary>
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
/// V2 supports TrueColor via new Color(r, g, b).
/// </summary>
private static Color ParseHexToTrueColor(string hex)
{
if (hex.StartsWith('#'))
hex = hex[1..];
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
return Color.White;
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return new Color(r, g, b);
}
private static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
}
+96
View File
@@ -0,0 +1,96 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using EchoHub.Core.Models;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the status dialog.
/// </summary>
public record StatusDialogResult(UserStatus Status, string? StatusMessage);
/// <summary>
/// A Terminal.Gui dialog for setting the user's status and status message.
/// </summary>
public sealed class StatusDialog
{
/// <summary>
/// Shows the status dialog and returns the result, or null if cancelled.
/// </summary>
public static StatusDialogResult? Show(IApplication app, UserStatus currentStatus, string? currentMessage)
{
StatusDialogResult? result = null;
var dialog = new Dialog { Title = "Set Status", Width = 50, Height = 12 };
var statusLabel = new Label
{
Text = "Status:",
X = 1,
Y = 1
};
var optionSelector = new OptionSelector<UserStatus>
{
X = 12,
Y = 1
};
optionSelector.Value = currentStatus;
var messageLabel = new Label
{
Text = "Message:",
X = 1,
Y = 6
};
var messageField = new TextField
{
Text = currentMessage ?? "",
X = 12,
Y = 6,
Width = Dim.Fill(2)
};
var saveButton = new Button
{
Text = "Save",
IsDefault = true,
X = Pos.Center() - 10,
Y = 8
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 8
};
saveButton.Accepting += (s, e) =>
{
var status = optionSelector.Value ?? UserStatus.Online;
var message = messageField.Text?.Trim();
if (string.IsNullOrWhiteSpace(message))
message = null;
result = new StatusDialogResult(status, message);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(statusLabel, optionSelector, messageLabel, messageField, saveButton, cancelButton);
optionSelector.SetFocus();
app.Run(dialog);
return result;
}
}
+336
View File
@@ -0,0 +1,336 @@
using System.Collections.ObjectModel;
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Client.Config;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Action selected by the user in the user panel dialog.
/// </summary>
public enum UserPanelAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// A Terminal.Gui dialog for viewing the user panel -- profile info, saved servers, and status.
/// </summary>
public sealed class UserPanelDialog
{
/// <summary>
/// Shows the user panel dialog and returns the action the user selected.
/// </summary>
public static UserPanelAction Show(
IApplication app,
UserProfileDto? profile,
List<SavedServer> savedServers,
UserStatus currentStatus,
string? currentStatusMessage)
{
var action = UserPanelAction.Close;
var dialog = new Dialog { Title = "User Panel", Width = 70, Height = 24 };
// -- Left side: Profile info ------------------------------------------
var profileFrame = new FrameView
{
Title = "Profile",
X = 0,
Y = 0,
Width = 35,
Height = Dim.Fill(3)
};
int row = 0;
// Username
var usernameLabel = new Label
{
Text = "Username:",
X = 1,
Y = row
};
var usernameValue = new Label
{
Text = profile?.Username ?? "N/A",
X = 12,
Y = row
};
usernameValue.SetScheme(new Scheme
{
Normal = new Attribute(Color.BrightYellow, Color.Blue)
});
profileFrame.Add(usernameLabel, usernameValue);
row += 1;
// Display Name
var displayLabel = new Label
{
Text = "Name:",
X = 1,
Y = row
};
var displayValue = new Label
{
Text = profile?.DisplayName ?? "-",
X = 12,
Y = row
};
profileFrame.Add(displayLabel, displayValue);
row += 1;
// Status
var statusLabel = new Label
{
Text = "Status:",
X = 1,
Y = row
};
var statusText = FormatStatus(currentStatus);
var statusValue = new Label
{
Text = statusText,
X = 12,
Y = row
};
statusValue.SetScheme(new Scheme
{
Normal = new Attribute(GetStatusColor(currentStatus), Color.Blue)
});
profileFrame.Add(statusLabel, statusValue);
row += 1;
// Status Message
if (!string.IsNullOrWhiteSpace(currentStatusMessage))
{
var msgLabel = new Label
{
Text = "Message:",
X = 1,
Y = row
};
var msgValue = new Label
{
Text = Truncate(currentStatusMessage, 20),
X = 12,
Y = row
};
profileFrame.Add(msgLabel, msgValue);
row += 1;
}
// Bio
row += 1;
var bioLabel = new Label
{
Text = "Bio:",
X = 1,
Y = row
};
profileFrame.Add(bioLabel);
row += 1;
var bioText = profile?.Bio ?? "-";
var bioView = new TextView()
{
X = 1,
Y = row,
Width = Dim.Fill(1),
Height = 3,
Text = bioText,
ReadOnly = true
};
bioView.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.DarkGray),
Focus = new Attribute(Color.White, Color.DarkGray)
});
profileFrame.Add(bioView);
row += 3;
// Color
var colorLabel = new Label
{
Text = "Color:",
X = 1,
Y = row
};
var colorValue = new Label
{
Text = profile?.NicknameColor ?? "-",
X = 12,
Y = row
};
profileFrame.Add(colorLabel, colorValue);
row += 1;
// ASCII Avatar
if (!string.IsNullOrWhiteSpace(profile?.AvatarAscii))
{
row += 1;
var avatarFrame = new FrameView
{
Title = "Avatar",
X = 1,
Y = row,
Width = Dim.Fill(1),
Height = 4
};
var avatarLabel = new Label
{
Text = profile.AvatarAscii,
X = 0,
Y = 0
};
avatarFrame.Add(avatarLabel);
profileFrame.Add(avatarFrame);
}
dialog.Add(profileFrame);
// -- Right side: Saved Servers ----------------------------------------
var serversFrame = new FrameView
{
Title = "Saved Servers",
X = 36,
Y = 0,
Width = Dim.Fill(1),
Height = Dim.Fill(3)
};
var serverNames = savedServers.Select(s => s.Name).ToList();
var serverList = new ListView
{
Source = new ListWrapper<string>(new ObservableCollection<string>(serverNames)),
X = 0,
Y = 0,
Width = Dim.Fill(0),
Height = Dim.Fill(4)
};
var serverUrlLabel = new Label
{
Text = "URL: -",
X = 0,
Y = Pos.AnchorEnd(3),
Width = Dim.Fill(0)
};
var serverLastLabel = new Label
{
Text = "Last: -",
X = 0,
Y = Pos.AnchorEnd(2),
Width = Dim.Fill(0)
};
var serverUserLabel = new Label
{
Text = "User: -",
X = 0,
Y = Pos.AnchorEnd(1),
Width = Dim.Fill(0)
};
serverList.ValueChanged += (sender, e) =>
{
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{
var server = savedServers[index.Value];
serverUrlLabel.Text = $"URL: {Truncate(server.Url, 25)}";
serverLastLabel.Text = $"Last: {server.LastConnected:yyyy-MM-dd HH:mm}";
serverUserLabel.Text = $"User: {server.Username ?? "-"}";
}
};
// Show initial details if there are servers
if (savedServers.Count > 0)
{
var first = savedServers[0];
serverUrlLabel.Text = $"URL: {Truncate(first.Url, 25)}";
serverLastLabel.Text = $"Last: {first.LastConnected:yyyy-MM-dd HH:mm}";
serverUserLabel.Text = $"User: {first.Username ?? "-"}";
}
serversFrame.Add(serverList, serverUrlLabel, serverLastLabel, serverUserLabel);
dialog.Add(serversFrame);
// -- Bottom buttons ---------------------------------------------------
var editProfileButton = new Button
{
Text = "Edit Profile",
X = Pos.Center() - 22,
Y = Pos.AnchorEnd(2)
};
var setStatusButton = new Button
{
Text = "Set Status",
X = Pos.Center() - 5,
Y = Pos.AnchorEnd(2)
};
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center() + 12,
Y = Pos.AnchorEnd(2)
};
editProfileButton.Accepting += (s, e) =>
{
action = UserPanelAction.EditProfile;
e.Handled = true;
app.RequestStop();
};
setStatusButton.Accepting += (s, e) =>
{
action = UserPanelAction.SetStatus;
e.Handled = true;
app.RequestStop();
};
closeButton.Accepting += (s, e) =>
{
action = UserPanelAction.Close;
e.Handled = true;
app.RequestStop();
};
dialog.Add(editProfileButton, setStatusButton, closeButton);
app.Run(dialog);
return action;
}
private static string FormatStatus(UserStatus status) => status switch
{
UserStatus.Online => "\u25cf Online",
UserStatus.Away => "\u25cf Away",
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
UserStatus.Invisible => "\u25cb Invisible",
_ => "\u25cf Unknown"
};
private static Color GetStatusColor(UserStatus status) => status switch
{
UserStatus.Online => Color.BrightGreen,
UserStatus.Away => Color.BrightYellow,
UserStatus.DoNotDisturb => Color.BrightRed,
UserStatus.Invisible => Color.Gray,
_ => Color.White
};
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength - 3), "...");
}