diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md
index bcbbe90..bbf91e4 100644
--- a/docs/changelog/v0.2.5.md
+++ b/docs/changelog/v0.2.5.md
@@ -102,6 +102,7 @@
### EchoHub Branding
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
+- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
## Infrastructure
diff --git a/docs/changelog/v0.2.6.md b/docs/changelog/v0.2.6.md
new file mode 100644
index 0000000..49a9919
--- /dev/null
+++ b/docs/changelog/v0.2.6.md
@@ -0,0 +1,20 @@
+# v0.2.6
+
+## Refactoring
+
+- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
+- Split `ChatRenderer.cs` (8 classes, 548 lines) into 7 individual files: `ChatSegment`, `ChatLine`, `ChatListSource`, `ChannelListSource`, `UserListSource`, `ChatColors`, `ColorHelper`, `RenderHelpers`
+- Extracted `ConnectionManager` from `AppOrchestrator` — connection lifecycle, authentication, SignalR event wiring, and channel tracking now in a dedicated service
+- Extracted `AvatarHelper` — deduplicated avatar upload logic previously duplicated in `/avatar` command and profile edit dialog
+- Consolidated `ProfileEditDialog.ParseHexToTrueColor` into shared `ColorHelper.ParseHexToColor`
+- Extracted `UserSession` — session state (`Username`, `Status`, `StatusMessage`) now in a dedicated class instead of scattered fields
+- Replaced 21 inline command handler lambdas with named `HandleCmd*` methods for improved readability
+- Extracted shared `CleanupConnectionAsync` to deduplicate disconnect/logout cleanup logic
+- Reorganized flat `UI/` folder (19 files) into subfolders: `Chat/`, `Dialogs/`, `ListSources/`, `Helpers/` with matching namespaces
+- Moved `AsyncRunner` from project root to `Services/` with updated namespace
+- Renamed `ColorHelper` → `HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
+- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
+
+## Infrastructure
+
+- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 381226e..87650ee 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,6 +1,6 @@
- 0.2.5
+ 0.2.6
true
$(NoWarn);CS1591
diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs
index 23449d2..19650ff 100644
--- a/src/EchoHub.Client/AppOrchestrator.cs
+++ b/src/EchoHub.Client/AppOrchestrator.cs
@@ -3,6 +3,8 @@ using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using EchoHub.Client.UI;
+using EchoHub.Client.UI.Chat;
+using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
@@ -14,28 +16,21 @@ namespace EchoHub.Client;
///
/// Central orchestrator for the EchoHub TUI client.
-/// Owns session state and wires UI events to service calls.
+/// Wires UI events to service calls and connection events to UI updates.
///
public sealed class AppOrchestrator : IDisposable
{
private readonly IApplication _app;
private readonly MainWindow _mainWindow;
+ private readonly ChatMessageManager _messageManager;
private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound;
private readonly AudioPlaybackService _audioPlayback = new();
private readonly UpdateChecker _updateService;
+ private readonly ConnectionManager _conn = new();
- private EchoHubConnection? _connection;
- private ApiClient? _apiClient;
- private readonly ClientEncryptionService _encryption = new();
private ClientConfig _config;
- private UserStatus _currentStatus = UserStatus.Online;
- private string? _currentStatusMessage;
- private string _currentUsername = string.Empty;
- private readonly HashSet _joinedChannels = [];
-
- private bool IsConnected => _connection is not null && _connection.IsConnected;
- private bool IsAuthenticated => _apiClient is not null;
+ private readonly UserSession _session = new();
public MainWindow MainWindow => _mainWindow;
@@ -43,13 +38,15 @@ public sealed class AppOrchestrator : IDisposable
{
_app = app;
_config = config;
- _mainWindow = new MainWindow(app);
+ _messageManager = new ChatMessageManager();
+ _mainWindow = new MainWindow(app, _messageManager);
_commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications);
_updateService = new UpdateChecker(app);
WireMainWindowEvents();
WireCommandHandlerEvents();
+ WireConnectionManagerEvents();
_updateService.Start();
@@ -58,8 +55,7 @@ public sealed class AppOrchestrator : IDisposable
public void Dispose()
{
- _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
- _apiClient?.Dispose();
+ _conn.DisposeAsync().AsTask().GetAwaiter().GetResult();
_updateService.Dispose();
}
@@ -95,283 +91,385 @@ public sealed class AppOrchestrator : IDisposable
private void WireCommandHandlerEvents()
{
- _commandHandler.OnSetStatus += async (status, message) =>
+ _commandHandler.OnSetStatus += HandleCmdSetStatus;
+ _commandHandler.OnSetNick += HandleCmdSetNick;
+ _commandHandler.OnSetColor += HandleCmdSetColor;
+ _commandHandler.OnSetTheme += HandleCmdSetTheme;
+ _commandHandler.OnSendFile += HandleCmdSendFile;
+ _commandHandler.OnSetAvatar += HandleCmdSetAvatar;
+ _commandHandler.OnOpenProfile += HandleCmdOpenProfile;
+ _commandHandler.OnOpenServers += HandleCmdOpenServers;
+ _commandHandler.OnJoinChannel += HandleCmdJoinChannel;
+ _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
+ _commandHandler.OnSetTopic += HandleCmdSetTopic;
+ _commandHandler.OnListUsers += HandleCmdListUsers;
+ _commandHandler.OnKickUser += HandleCmdKickUser;
+ _commandHandler.OnBanUser += HandleCmdBanUser;
+ _commandHandler.OnUnbanUser += HandleCmdUnbanUser;
+ _commandHandler.OnMuteUser += HandleCmdMuteUser;
+ _commandHandler.OnUnmuteUser += HandleCmdUnmuteUser;
+ _commandHandler.OnAssignRole += HandleCmdAssignRole;
+ _commandHandler.OnNukeChannel += HandleCmdNukeChannel;
+ _commandHandler.OnTestSound += HandleCmdTestSound;
+ _commandHandler.OnQuit += HandleCmdQuit;
+ }
+
+ // ── Command Handlers ──────────────────────────────────────────────────
+
+ private async Task HandleCmdSetStatus(UserStatus status, string? message)
+ {
+ if (!_conn.IsConnected) return;
+
+ await _conn.UpdateStatusAsync(status, message);
+ _session.Status = status;
+ _session.StatusMessage = message;
+ }
+
+ private async Task HandleCmdSetNick(string displayName)
+ {
+ if (!_conn.IsAuthenticated) return;
+
+ await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName));
+ InvokeUI(() =>
{
- if (!IsConnected) return;
+ _mainWindow.SetCurrentUser(displayName);
+ _mainWindow.UpdateStatusBar("Connected");
+ });
+ }
- await _connection!.UpdateStatusAsync(status, message);
- _currentStatus = status;
- _currentStatusMessage = message;
- };
+ private async Task HandleCmdSetColor(string color)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color));
+ }
- _commandHandler.OnSetNick += async (displayName) =>
+ private Task HandleCmdSetTheme(string name)
+ {
+ InvokeUI(() => HandleThemeSelected(name));
+ return Task.CompletedTask;
+ }
+
+ private async Task HandleCmdSendFile(string target, string? size)
+ {
+ if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
+
+ var channel = _mainWindow.CurrentChannel;
+ if (string.IsNullOrEmpty(channel)) return;
+
+ try
{
- if (!IsAuthenticated) return;
+ if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
+ && (uri.Scheme == "http" || uri.Scheme == "https"))
+ {
+ await _conn.Api!.SendUrlAsync(channel, target, size);
+ }
+ else
+ {
+ await using var stream = File.OpenRead(target);
+ var fileName = Path.GetFileName(target);
+ await _conn.Api!.UploadFileAsync(channel, stream, fileName, size);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "File send failed for {Target}", target);
+ InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
+ }
+ }
- await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest(DisplayName: displayName));
+ private async Task HandleCmdSetAvatar(string target)
+ {
+ if (!_conn.IsAuthenticated) return;
+
+ try
+ {
+ await AvatarHelper.UploadAsync(_conn.Api!, target);
+ var channel = _mainWindow.CurrentChannel;
+ if (!string.IsNullOrEmpty(channel))
+ InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated."));
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Avatar upload failed for {Target}", target);
+ InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
+ }
+ }
+
+ private Task HandleCmdOpenProfile(string? username)
+ {
+ InvokeUI(() => HandleViewProfile(username));
+ return Task.CompletedTask;
+ }
+
+ private Task HandleCmdOpenServers()
+ {
+ InvokeUI(HandleSavedServersRequested);
+ return Task.CompletedTask;
+ }
+
+ private async Task HandleCmdJoinChannel(string channelName)
+ {
+ if (!_conn.IsConnected) return;
+
+ try
+ {
+ var history = await _conn.JoinChannelAsync(channelName);
InvokeUI(() =>
{
- _mainWindow.SetCurrentUser(displayName);
- _mainWindow.UpdateStatusBar("Connected");
+ _mainWindow.EnsureChannelInList(channelName);
+ _mainWindow.SwitchToChannel(channelName);
+ if (history.Count > 0)
+ _messageManager.LoadHistory(channelName, history);
+ });
+ }
+ catch (Exception ex)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"Failed to join channel: {ex.Message}"));
+ }
+ }
+
+ private async Task HandleCmdLeaveChannel()
+ {
+ if (!_conn.IsConnected) return;
+
+ var channel = _mainWindow.CurrentChannel;
+ if (string.IsNullOrEmpty(channel)) return;
+
+ if (channel == HubConstants.DefaultChannel)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
+ return;
+ }
+
+ try
+ {
+ await _conn.LeaveChannelAsync(channel);
+ InvokeUI(() => _messageManager.AddSystemMessage(channel, $"You left #{channel}"));
+ }
+ catch (Exception ex)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"Failed to leave channel: {ex.Message}"));
+ }
+ }
+
+ private async Task HandleCmdSetTopic(string topic)
+ {
+ if (!_conn.IsAuthenticated) return;
+
+ var channel = _mainWindow.CurrentChannel;
+ if (string.IsNullOrEmpty(channel)) return;
+
+ try
+ {
+ await _conn.Api!.UpdateChannelTopicAsync(channel, topic);
+ InvokeUI(() =>
+ {
+ _mainWindow.SetChannelTopic(channel, topic);
+ _messageManager.AddSystemMessage(channel, $"Topic set to: {topic}");
+ });
+ }
+ catch (Exception ex)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"Failed to set topic: {ex.Message}"));
+ }
+ }
+
+ private async Task HandleCmdListUsers()
+ {
+ if (!_conn.IsConnected) return;
+
+ var channel = _mainWindow.CurrentChannel;
+ if (string.IsNullOrEmpty(channel)) return;
+
+ try
+ {
+ var users = await _conn.GetOnlineUsersAsync(channel);
+ InvokeUI(() =>
+ {
+ _messageManager.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}";
+ _messageManager.AddSystemMessage(channel, $" {displayName} ({statusText})");
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ InvokeUI(() => _mainWindow.ShowError($"Failed to list users: {ex.Message}"));
+ }
+ }
+
+ private async Task HandleCmdKickUser(string username, string? reason)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.KickUserAsync(username, reason);
+ }
+
+ private async Task HandleCmdBanUser(string username, string? reason)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.BanUserAsync(username, reason);
+ }
+
+ private async Task HandleCmdUnbanUser(string username)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.UnbanUserAsync(username);
+ }
+
+ private async Task HandleCmdMuteUser(string username, int? duration)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.MuteUserAsync(username, duration);
+ }
+
+ private async Task HandleCmdUnmuteUser(string username)
+ {
+ if (!_conn.IsAuthenticated) return;
+ await _conn.Api!.UnmuteUserAsync(username);
+ }
+
+ private async Task HandleCmdAssignRole(string username, string roleStr)
+ {
+ if (!_conn.IsAuthenticated) return;
+ var role = roleStr switch
+ {
+ "admin" => ServerRole.Admin,
+ "mod" => ServerRole.Mod,
+ _ => ServerRole.Member,
+ };
+ await _conn.Api!.AssignRoleAsync(username, role);
+ }
+
+ private async Task HandleCmdNukeChannel()
+ {
+ if (!_conn.IsAuthenticated) return;
+ var channel = _mainWindow.CurrentChannel;
+ if (string.IsNullOrEmpty(channel)) return;
+ await _conn.Api!.NukeChannelAsync(channel);
+ }
+
+ private async Task HandleCmdTestSound()
+ {
+ await _notificationSound.PlayTestAsync();
+ }
+
+ private Task HandleCmdQuit()
+ {
+ InvokeUI(() => _app.RequestStop());
+ return Task.CompletedTask;
+ }
+
+ // ── ConnectionManager Event Wiring ─────────────────────────────────────
+
+ private void WireConnectionManagerEvents()
+ {
+ _conn.MessageReceived += message =>
+ {
+ InvokeUI(() => _messageManager.AddMessage(message));
+
+ if (!string.IsNullOrEmpty(_session.Username)
+ && message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
+ {
+ _ = _notificationSound.PlayAsync();
+ }
+ };
+
+ _conn.UserJoined += (channelName, username) =>
+ {
+ InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
+ if (channelName == _mainWindow.CurrentChannel)
+ FetchAndUpdateOnlineUsers();
+ };
+
+ _conn.UserLeft += (channelName, username) =>
+ {
+ InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
+ if (channelName == _mainWindow.CurrentChannel)
+ FetchAndUpdateOnlineUsers();
+ };
+
+ _conn.UserStatusChanged += 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())
+ _messageManager.AddStatusMessage(channelName, displayName, statusText);
+ });
+ FetchAndUpdateOnlineUsers();
+ };
+
+ _conn.UserKicked += (channelName, username, reason) =>
+ {
+ var reasonText = reason is not null ? $" ({reason})" : "";
+ InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
+ };
+
+ _conn.UserBanned += (username, reason) =>
+ {
+ var reasonText = reason is not null ? $" ({reason})" : "";
+ InvokeUI(() =>
+ {
+ if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
+ {
+ var channel = _mainWindow.CurrentChannel;
+ if (!string.IsNullOrEmpty(channel))
+ _messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
+ }
});
};
- _commandHandler.OnSetColor += async (color) =>
+ _conn.ForceDisconnected += reason =>
{
- if (!IsAuthenticated) return;
-
- await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest(NicknameColor: color));
- };
-
- _commandHandler.OnSetTheme += (name) =>
- {
- InvokeUI(() => HandleThemeSelected(name));
- return Task.CompletedTask;
- };
-
- _commandHandler.OnSendFile += async (target, size) =>
- {
- if (!IsAuthenticated || !IsConnected) return;
-
- var channel = _mainWindow.CurrentChannel;
- if (string.IsNullOrEmpty(channel)) return;
-
- try
+ InvokeUI(() =>
{
- if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
- && (uri.Scheme == "http" || uri.Scheme == "https"))
- {
- await _apiClient!.SendUrlAsync(channel, target, size);
- }
- else
- {
- await using var stream = File.OpenRead(target);
- var fileName = Path.GetFileName(target);
- await _apiClient!.UploadFileAsync(channel, stream, fileName, size);
- }
- }
- catch (Exception ex)
+ _mainWindow.ShowError(reason);
+ HandleDisconnect();
+ });
+ };
+
+ _conn.MessageDeleted += (channelName, messageId) =>
+ InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
+
+ _conn.ChannelNuked += channelName =>
+ {
+ InvokeUI(() =>
{
- Log.Error(ex, "File send failed for {Target}", target);
- InvokeUI(() => _mainWindow.ShowError($"Send failed: {ex.Message}"));
- }
+ _messageManager.ClearChannelMessages(channelName);
+ _messageManager.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
+ });
};
- _commandHandler.OnSetAvatar += async (target) =>
+ _conn.ChannelUpdated += channel =>
{
- if (!IsAuthenticated) return;
-
- try
+ InvokeUI(() =>
{
- Stream stream;
- string fileName;
-
- if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
- && (uri.Scheme == "http" || uri.Scheme == "https"))
- {
- using var http = new HttpClient();
- var bytes = await http.GetByteArrayAsync(uri);
- stream = new MemoryStream(bytes);
- fileName = Path.GetFileName(uri.LocalPath);
- if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
- fileName = "avatar.png";
- }
- else
- {
- if (!File.Exists(target))
- {
- InvokeUI(() => _mainWindow.ShowError($"File not found: {target}"));
- return;
- }
- stream = File.OpenRead(target);
- fileName = Path.GetFileName(target);
- }
-
- await using (stream)
- {
- var ascii = await _apiClient!.UploadAvatarAsync(stream, fileName);
- var channel = _mainWindow.CurrentChannel;
- if (!string.IsNullOrEmpty(channel))
- InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
- }
- }
- catch (Exception ex)
- {
- Log.Error(ex, "Avatar upload failed for {Target}", target);
- InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}"));
- }
+ if (channel.IsPublic)
+ _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
+ _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
+ });
};
- _commandHandler.OnOpenProfile += (username) =>
+ _conn.Error += errorMessage =>
+ InvokeUI(() => _mainWindow.ShowError(errorMessage));
+
+ _conn.ConnectionStatusChanged += status =>
+ InvokeUI(() => _mainWindow.UpdateStatusBar(status));
+
+ _conn.Reconnected += () =>
{
- InvokeUI(() => HandleViewProfile(username));
- return Task.CompletedTask;
- };
-
- _commandHandler.OnOpenServers += () =>
- {
- InvokeUI(HandleSavedServersRequested);
- return Task.CompletedTask;
- };
-
- _commandHandler.OnJoinChannel += async (channelName) =>
- {
- if (!IsConnected) return;
-
- try
- {
- _joinedChannels.Add(channelName);
- var history = await _connection!.JoinChannelAsync(channelName);
- InvokeUI(() =>
- {
- // Add to channel list if not already there (e.g. private channels)
- _mainWindow.EnsureChannelInList(channelName);
- _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;
-
- if (channel == HubConstants.DefaultChannel)
- {
- InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
- return;
- }
-
- try
- {
- await _connection!.LeaveChannelAsync(channel);
- _joinedChannels.Remove(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.SetChannelTopic(channel, topic);
- _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.OnKickUser += async (username, reason) =>
- {
- if (!IsAuthenticated) return;
- await _apiClient!.KickUserAsync(username, reason);
- };
-
- _commandHandler.OnBanUser += async (username, reason) =>
- {
- if (!IsAuthenticated) return;
- await _apiClient!.BanUserAsync(username, reason);
- };
-
- _commandHandler.OnUnbanUser += async (username) =>
- {
- if (!IsAuthenticated) return;
- await _apiClient!.UnbanUserAsync(username);
- };
-
- _commandHandler.OnMuteUser += async (username, duration) =>
- {
- if (!IsAuthenticated) return;
- await _apiClient!.MuteUserAsync(username, duration);
- };
-
- _commandHandler.OnUnmuteUser += async (username) =>
- {
- if (!IsAuthenticated) return;
- await _apiClient!.UnmuteUserAsync(username);
- };
-
- _commandHandler.OnAssignRole += async (username, roleStr) =>
- {
- if (!IsAuthenticated) return;
- var role = roleStr switch
- {
- "admin" => ServerRole.Admin,
- "mod" => ServerRole.Mod,
- _ => ServerRole.Member,
- };
- await _apiClient!.AssignRoleAsync(username, role);
- };
-
- _commandHandler.OnNukeChannel += async () =>
- {
- if (!IsAuthenticated) return;
- var channel = _mainWindow.CurrentChannel;
- if (string.IsNullOrEmpty(channel)) return;
- await _apiClient!.NukeChannelAsync(channel);
- };
-
- _commandHandler.OnTestSound += async () =>
- {
- await _notificationSound.PlayTestAsync();
- };
-
- _commandHandler.OnQuit += () =>
- {
- InvokeUI(() => _app.RequestStop());
- return Task.CompletedTask;
+ RunAsync(
+ async () => await _conn.RejoinChannelsAsync(),
+ "Failed to rejoin channels after reconnect");
};
}
@@ -379,7 +477,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleConnect()
{
- if (IsConnected)
+ if (_conn.IsConnected)
{
var confirm = MessageBox.Query(_app, "Already Connected",
"You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel");
@@ -389,122 +487,47 @@ public sealed class AppOrchestrator : IDisposable
HandleDisconnect();
}
- var result = ConnectDialog.Show(_app, _config.SavedServers);
- if (result is null) return;
+ var dialogResult = ConnectDialog.Show(_app, _config.SavedServers);
+ if (dialogResult is null) return;
Log.Information("Connecting to {Url} as {User} (register={IsRegister})",
- result.ServerUrl, result.Username, result.IsRegister);
+ dialogResult.ServerUrl, dialogResult.Username, dialogResult.IsRegister);
RunAsync(async () =>
{
- _apiClient?.Dispose();
- _apiClient = new ApiClient(result.ServerUrl);
-
- InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
-
- LoginResponse loginResponse;
-
- if (result.SavedRefreshToken is not null)
- {
- try
- {
- loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken);
- Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
- }
- catch (Exception ex)
- {
- Log.Warning(ex, "Saved session expired or revoked");
- ClearSavedToken(result.ServerUrl);
- InvokeUI(() =>
- {
- _mainWindow.UpdateStatusBar("Disconnected");
- MessageBox.ErrorQuery(_app, "Session Expired",
- "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
- });
- _apiClient.Dispose();
- _apiClient = null;
- return;
- }
- }
- else if (result.IsRegister)
- {
- loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
- }
- else
- {
- loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
- }
-
- _currentUsername = loginResponse.Username;
-
- // Persist rotated refresh tokens for Remember Me
- _apiClient.OnTokensRefreshed += () =>
- {
- if (_apiClient?.RefreshToken is null) return;
- var config = ConfigManager.Load();
- var server = config.SavedServers.FirstOrDefault(s =>
- string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
- if (server is not null && server.RememberMe)
- {
- server.RefreshToken = _apiClient.RefreshToken;
- ConfigManager.Save(config);
- }
- };
-
- // Fetch encryption key for E2E message encryption
- InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
+ ConnectResult result;
try
{
- var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
- _encryption.SetKey(encryptionKey);
- Log.Information("E2E encryption key established");
+ result = await _conn.ConnectAsync(dialogResult,
+ status => InvokeUI(() => _mainWindow.UpdateStatusBar(status)));
}
- catch (Exception ex)
+ catch (Exception ex) when (dialogResult.SavedRefreshToken is not null)
{
- Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
- }
-
- 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, _encryption);
- WireConnectionEvents(_connection);
- await _connection.ConnectAsync();
-
- var channels = await _apiClient.GetChannelsAsync();
- InvokeUI(() =>
- {
- _mainWindow.SetChannels(channels);
- _mainWindow.UpdateStatusBar("Connected");
- });
-
- _joinedChannels.Clear();
- _joinedChannels.Add(HubConstants.DefaultChannel);
- await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
- InvokeUI(() => _mainWindow.SwitchToChannel(HubConstants.DefaultChannel));
-
- try
- {
- var history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
+ Log.Warning(ex, "Saved session expired or revoked");
+ ClearSavedToken(dialogResult.ServerUrl);
InvokeUI(() =>
{
- _mainWindow.LoadHistory(HubConstants.DefaultChannel, history);
- _mainWindow.FocusInput();
+ _mainWindow.UpdateStatusBar("Disconnected");
+ MessageBox.ErrorQuery(_app, "Session Expired",
+ "Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
});
- }
- catch
- {
- // History might not be available
+ return;
}
+ _session.Username = result.Login.Username;
+
+ InvokeUI(() =>
+ {
+ _mainWindow.SetCurrentUser(result.Login.DisplayName ?? result.Login.Username);
+ _mainWindow.SetChannels(result.Channels);
+ _mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
+ if (result.DefaultHistory.Count > 0)
+ _messageManager.LoadHistory(HubConstants.DefaultChannel, result.DefaultHistory);
+ _mainWindow.FocusInput();
+ });
+
FetchAndUpdateOnlineUsers();
- SaveServerToConfig(result);
+ SaveServerToConfig(dialogResult);
}, "Connection failed", "Connect");
}
@@ -514,17 +537,7 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
- if (_connection is not null)
- {
- await _connection.DisconnectAsync();
- await _connection.DisposeAsync();
- _connection = null;
- }
-
- _apiClient?.Dispose();
- _apiClient = null;
- _joinedChannels.Clear();
-
+ await _conn.CleanupAsync();
InvokeUI(() =>
{
_mainWindow.ClearAll();
@@ -539,24 +552,13 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
- if (_apiClient is not null)
- {
- var baseUrl = _apiClient.BaseUrl;
- await _apiClient.LogoutAsync();
+ var baseUrl = _conn.Api?.BaseUrl;
+ await _conn.LogoutAsync();
+
+ if (baseUrl is not null)
ClearSavedToken(baseUrl);
- }
-
- if (_connection is not null)
- {
- await _connection.DisconnectAsync();
- await _connection.DisposeAsync();
- _connection = null;
- }
-
- _apiClient?.Dispose();
- _apiClient = null;
- _joinedChannels.Clear();
+ await _conn.CleanupAsync();
InvokeUI(() =>
{
_mainWindow.ClearAll();
@@ -567,7 +569,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleMessageSubmitted(string channelName, string content)
{
- if (!IsConnected)
+ if (!_conn.IsConnected)
{
_mainWindow.ShowError("Not connected to a server.");
return;
@@ -585,7 +587,7 @@ public sealed class AppOrchestrator : IDisposable
if (result.IsError)
_mainWindow.ShowError(result.Message);
else
- _mainWindow.AddSystemMessage(channelName, result.Message);
+ _messageManager.AddSystemMessage(channelName, result.Message);
});
}
}, "Command failed");
@@ -593,23 +595,23 @@ public sealed class AppOrchestrator : IDisposable
}
RunAsync(
- async () => await _connection!.SendMessageAsync(channelName, content),
+ async () => await _conn.SendMessageAsync(channelName, content),
"Send failed");
}
private void HandleChannelSelected(string channelName)
{
- if (!IsConnected) return;
+ if (!_conn.IsConnected) return;
RunAsync(async () =>
{
- if (_joinedChannels.Add(channelName))
- await _connection!.JoinChannelAsync(channelName);
+ if (_conn.TrackChannel(channelName))
+ await _conn.JoinChannelAsync(channelName);
try
{
- var history = await _connection!.GetHistoryAsync(channelName);
- InvokeUI(() => _mainWindow.LoadHistory(channelName, history));
+ var history = await _conn.GetHistoryAsync(channelName);
+ InvokeUI(() => _messageManager.LoadHistory(channelName, history));
}
catch
{
@@ -627,20 +629,19 @@ public sealed class AppOrchestrator : IDisposable
private void HandleViewProfile(string? username)
{
- // If no username or it's our own, show the full user panel
var isOwnProfile = string.IsNullOrWhiteSpace(username)
- || username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase);
+ || username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase);
Task.Run(async () =>
{
UserProfileDto? profile = null;
try
{
- if (IsAuthenticated)
+ if (_conn.IsAuthenticated)
{
- var target = isOwnProfile ? _currentUsername : username!;
+ var target = isOwnProfile ? _session.Username : username!;
if (!string.IsNullOrEmpty(target))
- profile = await _apiClient!.GetUserProfileAsync(target);
+ profile = await _conn.Api!.GetUserProfileAsync(target);
}
}
catch (Exception ex)
@@ -655,8 +656,8 @@ public sealed class AppOrchestrator : IDisposable
{
var action = ProfileViewDialog.ShowOwn(_app,
profile,
- _currentStatus,
- _currentStatusMessage);
+ _session.Status,
+ _session.StatusMessage);
switch (action)
{
@@ -689,9 +690,9 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
- if (!IsAuthenticated) return;
+ if (!_conn.IsAuthenticated) return;
- await _apiClient!.UpdateProfileAsync(new UpdateProfileRequest(
+ await _conn.Api!.UpdateProfileAsync(new UpdateProfileRequest(
editResult.DisplayName,
editResult.Bio,
editResult.NicknameColor));
@@ -710,32 +711,10 @@ public sealed class AppOrchestrator : IDisposable
{
try
{
- Stream stream;
- string fileName;
-
- if (Uri.TryCreate(editResult.AvatarPath, UriKind.Absolute, out var uri)
- && (uri.Scheme == "http" || uri.Scheme == "https"))
- {
- using var http = new HttpClient();
- var bytes = await http.GetByteArrayAsync(uri);
- stream = new MemoryStream(bytes);
- fileName = Path.GetFileName(uri.LocalPath);
- if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
- fileName = "avatar.png";
- }
- else
- {
- stream = File.OpenRead(editResult.AvatarPath);
- fileName = Path.GetFileName(editResult.AvatarPath);
- }
-
- await using (stream)
- {
- await _apiClient!.UploadAvatarAsync(stream, fileName);
- var channel = _mainWindow.CurrentChannel;
- if (!string.IsNullOrEmpty(channel))
- InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated."));
- }
+ await AvatarHelper.UploadAsync(_conn.Api!, editResult.AvatarPath);
+ var channel = _mainWindow.CurrentChannel;
+ if (!string.IsNullOrEmpty(channel))
+ InvokeUI(() => _messageManager.AddSystemMessage(channel, "Avatar updated."));
}
catch (Exception ex)
{
@@ -768,16 +747,16 @@ public sealed class AppOrchestrator : IDisposable
private void HandleStatusRequested()
{
- var result = StatusDialog.Show(_app, _currentStatus, _currentStatusMessage);
+ var result = StatusDialog.Show(_app, _session.Status, _session.StatusMessage);
if (result is null) return;
- _currentStatus = result.Status;
- _currentStatusMessage = result.StatusMessage;
+ _session.Status = result.Status;
+ _session.StatusMessage = result.StatusMessage;
- if (IsConnected)
+ if (_conn.IsConnected)
{
RunAsync(
- async () => await _connection!.UpdateStatusAsync(result.Status, result.StatusMessage),
+ async () => await _conn.UpdateStatusAsync(result.Status, result.StatusMessage),
"Status update failed");
}
}
@@ -822,7 +801,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleCreateChannelRequested()
{
- if (!IsAuthenticated || !IsConnected)
+ if (!_conn.IsAuthenticated || !_conn.IsConnected)
{
_mainWindow.ShowError("Not connected to a server.");
return;
@@ -833,11 +812,10 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
- var channel = await _apiClient!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
+ var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic);
if (channel is null) return;
- _joinedChannels.Add(channel.Name);
- var history = await _connection!.JoinChannelAsync(channel.Name);
+ var history = await _conn.JoinChannelAsync(channel.Name);
InvokeUI(() =>
{
@@ -845,14 +823,14 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
_mainWindow.SwitchToChannel(channel.Name);
if (history.Count > 0)
- _mainWindow.LoadHistory(channel.Name, history);
+ _messageManager.LoadHistory(channel.Name, history);
});
}, "Failed to create channel");
}
private void HandleDeleteChannelRequested()
{
- if (!IsAuthenticated || !IsConnected)
+ if (!_conn.IsAuthenticated || !_conn.IsConnected)
{
_mainWindow.ShowError("Not connected to a server.");
return;
@@ -878,38 +856,38 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
- await _apiClient!.DeleteChannelAsync(channel);
- _joinedChannels.Remove(channel);
+ await _conn.Api!.DeleteChannelAsync(channel);
+ _conn.UntrackChannel(channel);
InvokeUI(() =>
{
_mainWindow.RemoveChannel(channel);
_mainWindow.SwitchToChannel(HubConstants.DefaultChannel);
- _mainWindow.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
+ _messageManager.AddSystemMessage(HubConstants.DefaultChannel, $"Channel #{channel} has been deleted.");
});
}, "Failed to delete channel");
}
private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
{
- if (!IsAuthenticated) return;
+ if (!_conn.IsAuthenticated) return;
RunAsync(async () =>
{
- InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
- var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
+ InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
+ var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
}, "Failed to play audio");
}
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
{
- if (!IsAuthenticated) return;
+ if (!_conn.IsAuthenticated) return;
RunAsync(async () =>
{
- InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
- var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
+ InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
+ var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
try
{
@@ -919,154 +897,23 @@ public sealed class AppOrchestrator : IDisposable
catch (Exception ex)
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
- InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
+ InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}, "Failed to download file");
}
- // ── Connection Event Wiring ────────────────────────────────────────────
-
- private void WireConnectionEvents(EchoHubConnection connection)
- {
- connection.OnMessageReceived += message =>
- {
- InvokeUI(() => _mainWindow.AddMessage(message));
-
- if (!string.IsNullOrEmpty(_currentUsername)
- && message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
- {
- _ = _notificationSound.PlayAsync();
- }
- };
-
- connection.OnUserJoined += (channelName, username) =>
- {
- InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} joined the channel"));
- if (channelName == _mainWindow.CurrentChannel)
- FetchAndUpdateOnlineUsers();
- };
-
- connection.OnUserLeft += (channelName, username) =>
- {
- InvokeUI(() => _mainWindow.AddSystemMessage(channelName, $"{username} left the channel"));
- if (channelName == _mainWindow.CurrentChannel)
- FetchAndUpdateOnlineUsers();
- };
-
- 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);
- });
- FetchAndUpdateOnlineUsers();
- };
-
- connection.OnUserKicked += (channelName, username, reason) =>
- {
- var reasonText = reason is not null ? $" ({reason})" : "";
- InvokeUI(() =>
- {
- _mainWindow.AddSystemMessage(channelName, $"{username} was kicked{reasonText}");
- });
- };
-
- connection.OnUserBanned += (username, reason) =>
- {
- var reasonText = reason is not null ? $" ({reason})" : "";
- InvokeUI(() =>
- {
- // Show ban notification for other users in the channel
- if (!username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase))
- {
- var channel = _mainWindow.CurrentChannel;
- if (!string.IsNullOrEmpty(channel))
- _mainWindow.AddSystemMessage(channel, $"{username} was banned{reasonText}");
- }
- });
- };
-
- connection.OnForceDisconnect += reason =>
- {
- InvokeUI(() =>
- {
- _mainWindow.ShowError(reason);
- HandleDisconnect();
- });
- };
-
- connection.OnMessageDeleted += (channelName, messageId) =>
- {
- InvokeUI(() =>
- {
- _mainWindow.RemoveMessage(channelName, messageId);
- });
- };
-
- connection.OnChannelNuked += channelName =>
- {
- InvokeUI(() =>
- {
- _mainWindow.ClearChannelMessages(channelName);
- _mainWindow.AddSystemMessage(channelName, "Channel history has been cleared by a moderator.");
- });
- };
-
- connection.OnChannelUpdated += channel =>
- {
- InvokeUI(() =>
- {
- if (channel.IsPublic)
- _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
- _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
- });
- };
-
- connection.OnError += errorMessage =>
- InvokeUI(() => _mainWindow.ShowError(errorMessage));
-
- connection.OnConnectionStateChanged += status =>
- InvokeUI(() => _mainWindow.UpdateStatusBar(status));
-
- connection.OnReconnected += () =>
- {
- // Server-side state is lost on reconnect — rejoin all channels
- var channels = _joinedChannels.ToList();
- if (channels.Count == 0) return;
-
- _joinedChannels.Clear();
-
- RunAsync(async () =>
- {
- foreach (var channel in channels)
- {
- _joinedChannels.Add(channel);
- await _connection!.JoinChannelAsync(channel);
- }
-
- Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
- }, "Failed to rejoin channels after reconnect");
- };
- }
-
// ── Private Helpers ────────────────────────────────────────────────────
private void FetchAndUpdateOnlineUsers()
{
var channel = _mainWindow.CurrentChannel;
- if (string.IsNullOrEmpty(channel) || !IsConnected) return;
+ if (string.IsNullOrEmpty(channel) || !_conn.IsConnected) return;
Task.Run(async () =>
{
try
{
- var users = await _connection!.GetOnlineUsersAsync(channel);
+ var users = await _conn.GetOnlineUsersAsync(channel);
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
}
catch (Exception ex)
@@ -1083,7 +930,7 @@ public sealed class AppOrchestrator : IDisposable
Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl,
Username = result.Username,
- RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
+ RefreshToken = result.RememberMe ? _conn.Api!.RefreshToken : null,
RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now
};
diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj
index 27b0268..c5279c2 100644
--- a/src/EchoHub.Client/EchoHub.Client.csproj
+++ b/src/EchoHub.Client/EchoHub.Client.csproj
@@ -20,7 +20,6 @@
PreserveNewest
-
PreserveNewest
@@ -35,7 +34,7 @@
enable
enable
- hue_icon.ico
+ Assets\hue_icon.ico
diff --git a/src/EchoHub.Client/AsyncRunner.cs b/src/EchoHub.Client/Services/AsyncRunner.cs
similarity index 95%
rename from src/EchoHub.Client/AsyncRunner.cs
rename to src/EchoHub.Client/Services/AsyncRunner.cs
index 23d09d5..56718cd 100644
--- a/src/EchoHub.Client/AsyncRunner.cs
+++ b/src/EchoHub.Client/Services/AsyncRunner.cs
@@ -1,7 +1,7 @@
using Serilog;
using Terminal.Gui.App;
-namespace EchoHub.Client;
+namespace EchoHub.Client.Services;
///
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
diff --git a/src/EchoHub.Client/Services/AvatarHelper.cs b/src/EchoHub.Client/Services/AvatarHelper.cs
new file mode 100644
index 0000000..95c22b3
--- /dev/null
+++ b/src/EchoHub.Client/Services/AvatarHelper.cs
@@ -0,0 +1,44 @@
+using Serilog;
+
+namespace EchoHub.Client.Services;
+
+///
+/// Shared avatar upload logic — resolves a file path or URL to a stream
+/// and uploads it via ApiClient.
+///
+internal static class AvatarHelper
+{
+ ///
+ /// Upload an avatar from a local file path or HTTP(S) URL.
+ /// Returns the ASCII art response from the server.
+ ///
+ public static async Task UploadAsync(ApiClient apiClient, string target)
+ {
+ Stream stream;
+ string fileName;
+
+ if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
+ && (uri.Scheme == "http" || uri.Scheme == "https"))
+ {
+ using var http = new HttpClient();
+ var bytes = await http.GetByteArrayAsync(uri);
+ stream = new MemoryStream(bytes);
+ fileName = Path.GetFileName(uri.LocalPath);
+ if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
+ fileName = "avatar.png";
+ }
+ else
+ {
+ if (!File.Exists(target))
+ throw new FileNotFoundException($"File not found: {target}");
+
+ stream = File.OpenRead(target);
+ fileName = Path.GetFileName(target);
+ }
+
+ await using (stream)
+ {
+ return await apiClient.UploadAvatarAsync(stream, fileName);
+ }
+ }
+}
diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs
new file mode 100644
index 0000000..50cca1c
--- /dev/null
+++ b/src/EchoHub.Client/Services/ConnectionManager.cs
@@ -0,0 +1,268 @@
+using EchoHub.Client.Config;
+using EchoHub.Client.UI.Dialogs;
+using EchoHub.Core.Constants;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using Serilog;
+
+namespace EchoHub.Client.Services;
+
+///
+/// Result of a successful connection, returned to AppOrchestrator for UI updates.
+///
+internal record ConnectResult(
+ LoginResponse Login,
+ List Channels,
+ List DefaultHistory);
+
+///
+/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
+/// Fires events so AppOrchestrator can update the UI without managing connection internals.
+///
+internal sealed class ConnectionManager : IAsyncDisposable
+{
+ private EchoHubConnection? _connection;
+ private ApiClient? _apiClient;
+ private readonly ClientEncryptionService _encryption = new();
+ private readonly HashSet _joinedChannels = [];
+
+ // ── Properties ────────────────────────────────────────────────────────
+
+ public bool IsConnected => _connection?.IsConnected == true;
+ public bool IsAuthenticated => _apiClient is not null;
+ public ApiClient? Api => _apiClient;
+
+ // ── Events (forwarded from SignalR) ───────────────────────────────────
+
+ public event Action? MessageReceived;
+ public event Action? UserJoined;
+ public event Action? UserLeft;
+ public event Action? UserStatusChanged;
+ public event Action? UserKicked;
+ public event Action? UserBanned;
+ public event Action? ForceDisconnected;
+ public event Action? MessageDeleted;
+ public event Action? ChannelNuked;
+ public event Action? ChannelUpdated;
+ public event Action? Error;
+ public event Action? ConnectionStatusChanged;
+ public event Action? Reconnected;
+
+ // ── Connect ───────────────────────────────────────────────────────────
+
+ ///
+ /// Full connection flow: authenticate → encryption → SignalR → join default channel.
+ /// Calls with progress messages for UI updates.
+ /// Throws on auth failure (caller handles saved-session expiry, etc.).
+ ///
+ public async Task ConnectAsync(ConnectDialogResult info, Action onStatus)
+ {
+ _apiClient?.Dispose();
+ _apiClient = new ApiClient(info.ServerUrl);
+
+ onStatus("Authenticating...");
+
+ LoginResponse loginResponse;
+
+ if (info.SavedRefreshToken is not null)
+ {
+ try
+ {
+ loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
+ Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
+ }
+ catch
+ {
+ _apiClient.Dispose();
+ _apiClient = null;
+ throw; // Caller handles saved-session expiry
+ }
+ }
+ else if (info.IsRegister)
+ {
+ loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
+ }
+ else
+ {
+ loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
+ }
+
+ // Auto-persist rotated refresh tokens for Remember Me
+ _apiClient.OnTokensRefreshed += HandleTokensRefreshed;
+
+ // E2E encryption key
+ onStatus("Fetching encryption key...");
+ try
+ {
+ var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
+ _encryption.SetKey(encryptionKey);
+ Log.Information("E2E encryption key established");
+ }
+ catch (Exception ex)
+ {
+ Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
+ }
+
+ onStatus("Authenticated, connecting...");
+
+ if (_connection is not null)
+ await _connection.DisposeAsync();
+
+ _connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
+ WireConnectionEvents(_connection);
+ await _connection.ConnectAsync();
+
+ var channels = await _apiClient.GetChannelsAsync();
+ onStatus("Connected");
+
+ // Join default channel + fetch history
+ _joinedChannels.Clear();
+ _joinedChannels.Add(HubConstants.DefaultChannel);
+ await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
+
+ List history = [];
+ try
+ {
+ history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
+ }
+ catch
+ {
+ // History might not be available
+ }
+
+ return new ConnectResult(loginResponse, channels, history);
+ }
+
+ // ── Cleanup ───────────────────────────────────────────────────────────
+
+ ///
+ /// Disconnect and dispose connection + API client, clear channel tracking.
+ ///
+ public async Task CleanupAsync()
+ {
+ if (_connection is not null)
+ {
+ await _connection.DisconnectAsync();
+ await _connection.DisposeAsync();
+ _connection = null;
+ }
+
+ _apiClient?.Dispose();
+ _apiClient = null;
+ _joinedChannels.Clear();
+ }
+
+ ///
+ /// Revoke refresh token on the server. Call afterwards.
+ ///
+ public async Task LogoutAsync()
+ {
+ if (_apiClient is not null)
+ await _apiClient.LogoutAsync();
+ }
+
+ // ── Channel Operations ────────────────────────────────────────────────
+
+ public async Task> JoinChannelAsync(string channelName)
+ {
+ if (_connection is null) throw new InvalidOperationException("Not connected");
+ _joinedChannels.Add(channelName);
+ return await _connection.JoinChannelAsync(channelName);
+ }
+
+ public async Task LeaveChannelAsync(string channelName)
+ {
+ if (_connection is null) throw new InvalidOperationException("Not connected");
+ await _connection.LeaveChannelAsync(channelName);
+ _joinedChannels.Remove(channelName);
+ }
+
+ ///
+ /// Track a channel as joined (returns true if newly added).
+ ///
+ public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName);
+
+ public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName);
+
+ // ── Delegate Operations ───────────────────────────────────────────────
+
+ public Task SendMessageAsync(string channel, string content) =>
+ _connection?.SendMessageAsync(channel, content)
+ ?? throw new InvalidOperationException("Not connected");
+
+ public Task> GetHistoryAsync(string channel) =>
+ _connection?.GetHistoryAsync(channel)
+ ?? throw new InvalidOperationException("Not connected");
+
+ public Task> GetOnlineUsersAsync(string channel) =>
+ _connection?.GetOnlineUsersAsync(channel)
+ ?? throw new InvalidOperationException("Not connected");
+
+ public Task UpdateStatusAsync(UserStatus status, string? message) =>
+ _connection?.UpdateStatusAsync(status, message)
+ ?? throw new InvalidOperationException("Not connected");
+
+ // ── Reconnect ─────────────────────────────────────────────────────────
+
+ ///
+ /// Rejoin all previously tracked channels after a reconnect.
+ ///
+ public async Task RejoinChannelsAsync()
+ {
+ var channels = _joinedChannels.ToList();
+ if (channels.Count == 0 || _connection is null) return;
+
+ _joinedChannels.Clear();
+
+ foreach (var channel in channels)
+ {
+ _joinedChannels.Add(channel);
+ await _connection.JoinChannelAsync(channel);
+ }
+
+ Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
+ }
+
+ // ── SignalR Event Wiring ──────────────────────────────────────────────
+
+ private void WireConnectionEvents(EchoHubConnection connection)
+ {
+ connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
+ connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
+ connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
+ connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
+ connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
+ connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
+ connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
+ connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
+ connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
+ connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
+ connection.OnError += msg => Error?.Invoke(msg);
+ connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
+ connection.OnReconnected += () => Reconnected?.Invoke();
+ }
+
+ // ── Token Persistence ─────────────────────────────────────────────────
+
+ private void HandleTokensRefreshed()
+ {
+ if (_apiClient?.RefreshToken is null) return;
+ var config = ConfigManager.Load();
+ var server = config.SavedServers.FirstOrDefault(s =>
+ string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
+ if (server is not null && server.RememberMe)
+ {
+ server.RefreshToken = _apiClient.RefreshToken;
+ ConfigManager.Save(config);
+ }
+ }
+
+ // ── Dispose ───────────────────────────────────────────────────────────
+
+ public async ValueTask DisposeAsync()
+ {
+ _apiClient?.Dispose();
+ if (_connection is not null)
+ await _connection.DisposeAsync();
+ }
+}
diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs
index 9a204ad..5c1b37f 100644
--- a/src/EchoHub.Client/Services/UpdateChecker.cs
+++ b/src/EchoHub.Client/Services/UpdateChecker.cs
@@ -1,6 +1,6 @@
using AlwaysUpToDate;
-using EchoHub.Client.UI;
+using EchoHub.Client.UI.Dialogs;
using Serilog;
diff --git a/src/EchoHub.Client/Services/UserSession.cs b/src/EchoHub.Client/Services/UserSession.cs
new file mode 100644
index 0000000..3fc7693
--- /dev/null
+++ b/src/EchoHub.Client/Services/UserSession.cs
@@ -0,0 +1,20 @@
+using EchoHub.Core.Models;
+
+namespace EchoHub.Client.Services;
+
+///
+/// Holds the current user's session state (username, status, status message).
+///
+internal sealed class UserSession
+{
+ public string Username { get; set; } = string.Empty;
+ public UserStatus Status { get; set; } = UserStatus.Online;
+ public string? StatusMessage { get; set; }
+
+ public void Reset()
+ {
+ Username = string.Empty;
+ Status = UserStatus.Online;
+ StatusMessage = null;
+ }
+}
diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs
new file mode 100644
index 0000000..aeeb2ab
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs
@@ -0,0 +1,49 @@
+using System.Text.RegularExpressions;
+using Terminal.Gui.Drawing;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// Shared color attributes for chat rendering (timestamps, system messages).
+///
+public static partial class ChatColors
+{
+ public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
+ public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
+ public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
+ public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
+ public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
+ public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
+ public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
+ public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
+ public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
+ public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
+
+ ///
+ /// Split text around @mentions, giving each @word the MentionTextAttr accent color.
+ /// Non-mention text uses the provided default color.
+ ///
+ public static List SplitMentions(string text, Attribute? defaultColor = null)
+ {
+ var segments = new List();
+ int lastIndex = 0;
+
+ foreach (Match match in MentionRegex().Matches(text))
+ {
+ if (match.Index > lastIndex)
+ segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
+
+ segments.Add(new ChatSegment(match.Value, MentionTextAttr));
+ lastIndex = match.Index + match.Length;
+ }
+
+ if (lastIndex < text.Length)
+ segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
+
+ return segments;
+ }
+
+ [GeneratedRegex(@"@[\w-]+")]
+ private static partial Regex MentionRegex();
+}
diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs
new file mode 100644
index 0000000..cf230cc
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs
@@ -0,0 +1,177 @@
+using System.Text.RegularExpressions;
+using EchoHub.Core.Models;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// A single line in the chat, composed of colored segments.
+///
+public partial class ChatLine
+{
+ public List Segments { get; }
+ public int TextLength { get; }
+ public Guid? MessageId { get; set; }
+ public bool IsMention { get; set; }
+ public string? AttachmentUrl { get; set; }
+ public string? AttachmentFileName { get; set; }
+ public MessageType? Type { get; set; }
+
+ public ChatLine(string plainText)
+ {
+ Segments = [new ChatSegment(plainText, null)];
+ TextLength = plainText.GetColumns();
+ }
+
+ public ChatLine(List segments)
+ {
+ Segments = segments;
+ TextLength = segments.Sum(s => s.Text.GetColumns());
+ }
+
+ public override string ToString() => string.Concat(Segments.Select(s => s.Text));
+
+ ///
+ /// Wrap this line into multiple lines that fit within the given width.
+ /// Continuation lines are indented with the specified number of spaces.
+ ///
+ public List Wrap(int width, int continuationIndent = 0)
+ {
+ if (width <= 0 || TextLength <= width)
+ return [this];
+
+ var results = new List();
+ var currentSegments = new List();
+ int col = 0;
+
+ foreach (var segment in Segments)
+ {
+ var text = segment.Text;
+ int chunkStart = 0;
+ int charPos = 0;
+
+ foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
+ {
+ var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
+
+ if (col + graphemeCols > width)
+ {
+ if (charPos > chunkStart)
+ currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
+
+ results.Add(new ChatLine(currentSegments));
+ currentSegments = [];
+
+ if (continuationIndent > 0)
+ {
+ currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
+ col = continuationIndent;
+ }
+ else
+ {
+ col = 0;
+ }
+
+ chunkStart = charPos;
+ }
+
+ col += graphemeCols;
+ charPos += grapheme.Length;
+ }
+
+ if (chunkStart < text.Length)
+ currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
+ }
+
+ if (currentSegments.Count > 0)
+ results.Add(new ChatLine(currentSegments));
+
+ // Propagate attachment/type metadata to all wrapped lines so they remain clickable
+ foreach (var wrapped in results)
+ {
+ wrapped.AttachmentUrl = AttachmentUrl;
+ wrapped.AttachmentFileName = AttachmentFileName;
+ wrapped.Type = Type;
+ wrapped.MessageId = MessageId;
+ }
+
+ return results;
+ }
+
+ ///
+ /// Returns true if a line contains printable color tags.
+ ///
+ public static bool HasColorTags(string text) =>
+ text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
+
+ ///
+ /// Remove all color tags from text, returning only the visible characters.
+ ///
+ public static string StripColorTags(string text) =>
+ ColorTagRegex().Replace(text, "");
+
+ ///
+ /// Parse a string containing printable color tags into colored segments.
+ /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
+ ///
+ public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
+ {
+ var segments = new List();
+ int lastIndex = 0;
+ Color? currentFg = null;
+ Color? currentBg = null;
+ var defaultFg = defaultAttr?.Foreground;
+ var defaultBg = defaultAttr?.Background ?? Color.None;
+
+ Attribute? BuildAttr()
+ {
+ if (currentFg is null && currentBg is null) return defaultAttr;
+ var fg = currentFg ?? defaultFg ?? Color.White;
+ var bg = currentBg ?? defaultBg;
+ return new Attribute(fg, bg);
+ }
+
+ foreach (Match match in ColorTagRegex().Matches(text))
+ {
+ if (match.Index > lastIndex)
+ {
+ var t = text[lastIndex..match.Index];
+ if (t.Length > 0)
+ segments.Add(new ChatSegment(t, BuildAttr()));
+ }
+
+ if (match.Groups[1].Success)
+ {
+ currentFg = null;
+ currentBg = null;
+ }
+ else if (match.Groups[2].Success)
+ {
+ var hex = match.Groups[3].Value;
+ var r = Convert.ToInt32(hex[..2], 16);
+ var g = Convert.ToInt32(hex[2..4], 16);
+ var b = Convert.ToInt32(hex[4..6], 16);
+ if (match.Groups[2].Value == "F")
+ currentFg = new Color(r, g, b);
+ else
+ currentBg = new Color(r, g, b);
+ }
+
+ lastIndex = match.Index + match.Length;
+ }
+
+ if (lastIndex < text.Length)
+ {
+ var t = text[lastIndex..];
+ if (t.Length > 0)
+ segments.Add(new ChatSegment(t, BuildAttr()));
+ }
+
+ return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
+ }
+
+ [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
+ private static partial Regex ColorTagRegex();
+}
diff --git a/src/EchoHub.Client/UI/Chat/ChatListSource.cs b/src/EchoHub.Client/UI/Chat/ChatListSource.cs
new file mode 100644
index 0000000..d999d72
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/ChatListSource.cs
@@ -0,0 +1,113 @@
+using System.Collections;
+using System.Collections.Specialized;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.Views;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// Custom list data source for chat messages with per-segment coloring.
+///
+public class ChatListSource : IListDataSource
+{
+ private readonly List _lines = [];
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+
+ public int Count => _lines.Count;
+ public int MaxItemLength { get; private set; }
+ public bool SuspendCollectionChangedEvent { get; set; }
+
+ public void Add(ChatLine line)
+ {
+ _lines.Add(line);
+ UpdateMaxLength(line);
+ RaiseCollectionChanged();
+ }
+
+ public void AddRange(IEnumerable lines)
+ {
+ foreach (var line in lines)
+ {
+ _lines.Add(line);
+ UpdateMaxLength(line);
+ }
+ RaiseCollectionChanged();
+ }
+
+ public void InsertRange(int index, IEnumerable lines)
+ {
+ var items = lines.ToList();
+ _lines.InsertRange(index, items);
+ foreach (var line in items)
+ UpdateMaxLength(line);
+ RaiseCollectionChanged();
+ }
+
+ public void Clear()
+ {
+ _lines.Clear();
+ MaxItemLength = 0;
+ RaiseCollectionChanged();
+ }
+
+ public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
+
+ public bool IsMarked(int item) => false;
+ public void SetMark(int item, bool value) { }
+ public IList ToList() => _lines.Select(l => l.ToString()).ToList();
+
+ public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
+ {
+ listView.Move(Math.Max(col - viewportX, 0), row);
+
+ var chatLine = _lines[item];
+ var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
+ var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
+
+ int charPos = 0;
+ int drawnChars = 0;
+
+ foreach (var segment in chatLine.Segments)
+ {
+ var attr = segment.Color ?? normalAttr;
+ if (attr.Background == Color.None)
+ attr = attr with { Background = normalAttr.Background };
+ if (mentionBg.HasValue)
+ attr = attr with { Background = mentionBg.Value };
+ listView.SetAttribute(attr);
+
+ foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
+ {
+ var cols = Math.Max(grapheme.GetColumns(), 1);
+ if (charPos >= viewportX && drawnChars + cols <= width)
+ {
+ listView.AddStr(grapheme);
+ drawnChars += cols;
+ }
+ charPos += cols;
+ }
+ }
+
+ var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
+ listView.SetAttribute(fillAttr);
+ for (int i = drawnChars; i < width; i++)
+ listView.AddStr(" ");
+ }
+
+ private void UpdateMaxLength(ChatLine line)
+ {
+ if (line.TextLength > MaxItemLength)
+ MaxItemLength = line.TextLength;
+ }
+
+ private void RaiseCollectionChanged()
+ {
+ if (!SuspendCollectionChangedEvent)
+ CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ public void Dispose() { }
+}
diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
new file mode 100644
index 0000000..533a4a1
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs
@@ -0,0 +1,400 @@
+using System.Text.RegularExpressions;
+using EchoHub.Client.UI.Helpers;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// Owns chat message storage, formatting, and mutation.
+/// Fires when a channel's message list is modified
+/// so the UI layer can refresh.
+///
+public sealed class ChatMessageManager
+{
+ private readonly Dictionary> _channelMessages = [];
+ private readonly Dictionary _channelUnread = [];
+
+ private string _currentUser = string.Empty;
+ private string _currentChannel = string.Empty;
+ private int _chatWidth;
+
+ ///
+ /// Fired after any mutation to a channel's messages. Parameter is the channel name.
+ ///
+ public event Action? MessagesChanged;
+
+ ///
+ /// The currently active channel (used for unread tracking and @mention detection).
+ ///
+ public string CurrentChannel
+ {
+ get => _currentChannel;
+ set => _currentChannel = value;
+ }
+
+ public string CurrentUser => _currentUser;
+
+ public void SetCurrentUser(string username) => _currentUser = username;
+
+ public void SetChatWidth(int width) => _chatWidth = width;
+
+ // ── Queries ──────────────────────────────────────────────────────
+
+ public List? GetMessages(string channelName)
+ {
+ return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
+ }
+
+ public int GetUnreadCount(string channelName)
+ {
+ return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
+ }
+
+ public void ClearUnread(string channelName)
+ {
+ _channelUnread[channelName] = 0;
+ }
+
+ internal Dictionary GetUnreadCounts() => _channelUnread;
+
+ // ── Mutations ────────────────────────────────────────────────────
+
+ ///
+ /// Format and store a received message. Increments unread count if not the active channel.
+ ///
+ 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)
+ {
+ MessagesChanged?.Invoke(message.ChannelName);
+ }
+ else
+ {
+ _channelUnread.TryGetValue(message.ChannelName, out var count);
+ _channelUnread[message.ChannelName] = count + 1;
+ MessagesChanged?.Invoke(message.ChannelName);
+ }
+ }
+
+ ///
+ /// Add a system/informational message to a channel with colored styling.
+ ///
+ public void AddSystemMessage(string channelName, string text)
+ {
+ if (!_channelMessages.TryGetValue(channelName, out var messages))
+ {
+ messages = [];
+ _channelMessages[channelName] = messages;
+ }
+
+ var time = DateTimeOffset.Now.ToString("HH:mm");
+ var textLines = text.Split('\n');
+
+ messages.Add(new ChatLine(
+ [
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
+ ]));
+
+ var indent = new string(' ', $"[{time}] ** ".Length);
+ for (int i = 1; i < textLines.Length; i++)
+ {
+ var line = textLines[i].TrimEnd('\r');
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ messages.Add(new ChatLine(
+ [
+ new($"{indent}{line}", ChatColors.SystemAttr)
+ ]));
+ }
+
+ if (channelName == _currentChannel)
+ MessagesChanged?.Invoke(channelName);
+ }
+
+ ///
+ /// Add a status change message to a channel with colored styling.
+ ///
+ public void AddStatusMessage(string channelName, string username, string status)
+ {
+ var time = DateTimeOffset.Now.ToString("HH:mm");
+ var segments = new List
+ {
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new($"** {username} is now {status}", ChatColors.SystemAttr)
+ };
+
+ if (!_channelMessages.TryGetValue(channelName, out var messages))
+ {
+ messages = [];
+ _channelMessages[channelName] = messages;
+ }
+ messages.Add(new ChatLine(segments));
+
+ if (channelName == _currentChannel)
+ MessagesChanged?.Invoke(channelName);
+ }
+
+ ///
+ /// Remove all lines associated with a specific message ID.
+ ///
+ public void RemoveMessage(string channelName, Guid messageId)
+ {
+ if (_channelMessages.TryGetValue(channelName, out var messages))
+ {
+ messages.RemoveAll(l => l.MessageId == messageId);
+ if (channelName == _currentChannel)
+ MessagesChanged?.Invoke(channelName);
+ }
+ }
+
+ ///
+ /// Clear all messages from a specific channel.
+ ///
+ public void ClearChannelMessages(string channelName)
+ {
+ if (_channelMessages.TryGetValue(channelName, out var messages))
+ {
+ messages.Clear();
+ if (channelName == _currentChannel)
+ MessagesChanged?.Invoke(channelName);
+ }
+ }
+
+ ///
+ /// Load historical messages into a channel, replacing any existing messages.
+ ///
+ public void LoadHistory(string channelName, List messages)
+ {
+ var formatted = messages.SelectMany(FormatMessage).ToList();
+ _channelMessages[channelName] = formatted;
+
+ if (channelName == _currentChannel)
+ MessagesChanged?.Invoke(channelName);
+ }
+
+ ///
+ /// Reset all message state (used on disconnect).
+ ///
+ public void ClearAll()
+ {
+ _channelMessages.Clear();
+ _channelUnread.Clear();
+ _currentChannel = string.Empty;
+ _currentUser = string.Empty;
+ }
+
+ // ── Formatting ───────────────────────────────────────────────────
+
+ private List FormatMessage(MessageDto message)
+ {
+ var time = message.SentAt.ToLocalTime().ToString("HH:mm");
+ var senderName = message.SenderUsername + ":";
+ var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
+
+ var lines = new List();
+
+ switch (message.Type)
+ {
+ case MessageType.Image:
+ lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
+ if (!string.IsNullOrWhiteSpace(message.Content))
+ {
+ foreach (var artLine in message.Content.Split('\n'))
+ {
+ var trimmed = artLine.TrimEnd('\r');
+ if (ChatLine.HasColorTags(trimmed))
+ lines.Add(ChatLine.FromColoredText(" " + trimmed));
+ else
+ lines.Add(new ChatLine($" {trimmed}"));
+ }
+ }
+ break;
+
+ case MessageType.Audio:
+ var audioName = message.AttachmentFileName ?? "unknown";
+ var audioSize = FormatFileSize(message.AttachmentFileSize);
+ var audioLine = BuildChatLineColored(time, senderName, senderColor,
+ $" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
+ audioLine.AttachmentUrl = message.AttachmentUrl;
+ audioLine.AttachmentFileName = audioName;
+ audioLine.Type = MessageType.Audio;
+ lines.Add(audioLine);
+ break;
+
+ case MessageType.File:
+ var fileName = message.AttachmentFileName ?? "unknown";
+ var fileSize = FormatFileSize(message.AttachmentFileSize);
+ var fileLine = BuildChatLineColored(time, senderName, senderColor,
+ $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
+ fileLine.AttachmentUrl = message.AttachmentUrl;
+ fileLine.AttachmentFileName = fileName;
+ fileLine.Type = MessageType.File;
+ lines.Add(fileLine);
+ break;
+
+ case MessageType.Text:
+ default:
+ var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
+ var contentLines = displayContent.Split('\n');
+ var firstLine = contentLines[0].TrimEnd('\r');
+ lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
+ var indent = new string(' ', $"[{time}] {senderName} ".Length);
+ for (int i = 1; i < contentLines.Length; i++)
+ {
+ var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
+ lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
+ }
+
+ if (message.Embeds is { Count: > 0 })
+ {
+ var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
+ foreach (var embed in message.Embeds)
+ lines.AddRange(FormatEmbed(embed, indent, chatWidth));
+ }
+ break;
+ }
+
+ foreach (var line in lines)
+ line.MessageId = message.Id;
+
+ if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
+ {
+ var pattern = $@"@{Regex.Escape(_currentUser)}\b";
+ if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
+ {
+ foreach (var line in lines)
+ line.IsMention = true;
+ }
+ }
+
+ return lines;
+ }
+
+ private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
+ {
+ var segments = new List
+ {
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new(senderName, senderColor),
+ new(suffix, null)
+ };
+ return new ChatLine(segments);
+ }
+
+ private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
+ {
+ var segments = new List
+ {
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new(senderName, senderColor),
+ new(suffix, suffixColor)
+ };
+ return new ChatLine(segments);
+ }
+
+ private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
+ {
+ var segments = new List
+ {
+ new($"[{time}] ", ChatColors.TimestampAttr),
+ new(senderName, senderColor),
+ };
+ segments.AddRange(ChatColors.SplitMentions(suffix));
+ return new ChatLine(segments);
+ }
+
+ private static List FormatEmbed(EmbedDto embed, string indent, int chatWidth)
+ {
+ var lines = new List();
+ const string border = "\u258f "; // ▏ + space
+ const int borderCols = 2;
+ int indentCols = indent.GetColumns();
+ int textWidth = chatWidth - indentCols - borderCols;
+ if (textWidth < 20) textWidth = 20;
+
+ void AddTextLine(string text, Attribute? color)
+ {
+ lines.Add(new ChatLine(
+ [
+ new ChatSegment(indent, null),
+ new ChatSegment(border, ChatColors.EmbedBorderAttr),
+ new ChatSegment(text, color)
+ ]));
+ }
+
+ if (!string.IsNullOrWhiteSpace(embed.SiteName))
+ AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
+
+ if (!string.IsNullOrWhiteSpace(embed.Title))
+ {
+ foreach (var wrapped in WordWrap(embed.Title, textWidth))
+ AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
+ }
+
+ if (!string.IsNullOrWhiteSpace(embed.Description))
+ {
+ foreach (var wrapped in WordWrap(embed.Description, textWidth))
+ AddTextLine(wrapped, ChatColors.EmbedDescAttr);
+ }
+
+ return lines;
+ }
+
+ private static List WordWrap(string text, int maxCols)
+ {
+ if (maxCols <= 0)
+ return [text];
+
+ var result = new List();
+ var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var currentLine = "";
+
+ foreach (var word in words)
+ {
+ var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
+ if (candidate.GetColumns() <= maxCols)
+ {
+ currentLine = candidate;
+ }
+ else
+ {
+ if (currentLine.Length > 0)
+ result.Add(currentLine);
+ currentLine = word;
+ }
+ }
+
+ if (currentLine.Length > 0)
+ result.Add(currentLine);
+
+ return result;
+ }
+
+ internal static string FormatFileSize(long? bytes)
+ {
+ if (bytes is null or 0)
+ return "?";
+
+ return bytes.Value switch
+ {
+ < 1024 => $"{bytes.Value} B",
+ < 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
+ < 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
+ _ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
+ };
+ }
+}
diff --git a/src/EchoHub.Client/UI/Chat/ChatSegment.cs b/src/EchoHub.Client/UI/Chat/ChatSegment.cs
new file mode 100644
index 0000000..3d0cc19
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/ChatSegment.cs
@@ -0,0 +1,8 @@
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// A colored text segment within a chat line.
+///
+public record ChatSegment(string Text, Attribute? Color);
diff --git a/src/EchoHub.Client/UI/Chat/RenderHelpers.cs b/src/EchoHub.Client/UI/Chat/RenderHelpers.cs
new file mode 100644
index 0000000..13d5593
--- /dev/null
+++ b/src/EchoHub.Client/UI/Chat/RenderHelpers.cs
@@ -0,0 +1,27 @@
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.Views;
+
+namespace EchoHub.Client.UI.Chat;
+
+///
+/// Shared rendering helpers for IListDataSource implementations.
+///
+static class RenderHelpers
+{
+ ///
+ /// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
+ /// Returns the updated drawn-columns count.
+ ///
+ public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
+ {
+ foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
+ {
+ var cols = Math.Max(grapheme.GetColumns(), 1);
+ if (drawn + cols > maxWidth) break;
+ lv.AddStr(grapheme);
+ drawn += cols;
+ }
+ return drawn;
+ }
+}
diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs
deleted file mode 100644
index c6830b2..0000000
--- a/src/EchoHub.Client/UI/ChatRenderer.cs
+++ /dev/null
@@ -1,547 +0,0 @@
-using System.Collections;
-using System.Collections.Specialized;
-using System.Text.RegularExpressions;
-using EchoHub.Core.Models;
-using Terminal.Gui.Drawing;
-using Terminal.Gui.Text;
-using Terminal.Gui.Views;
-using Attribute = Terminal.Gui.Drawing.Attribute;
-
-namespace EchoHub.Client.UI;
-
-///
-/// A colored text segment within a chat line.
-///
-public record ChatSegment(string Text, Attribute? Color);
-
-///
-/// A single line in the chat, composed of colored segments.
-///
-public partial class ChatLine
-{
- public List Segments { get; }
- public int TextLength { get; }
- public Guid? MessageId { get; set; }
- public bool IsMention { get; set; }
- public string? AttachmentUrl { get; set; }
- public string? AttachmentFileName { get; set; }
- public MessageType? Type { get; set; }
-
- public ChatLine(string plainText)
- {
- Segments = [new ChatSegment(plainText, null)];
- TextLength = plainText.GetColumns();
- }
-
- public ChatLine(List segments)
- {
- Segments = segments;
- TextLength = segments.Sum(s => s.Text.GetColumns());
- }
-
- public override string ToString() => string.Concat(Segments.Select(s => s.Text));
-
- ///
- /// Wrap this line into multiple lines that fit within the given width.
- /// Continuation lines are indented with the specified number of spaces.
- ///
- public List Wrap(int width, int continuationIndent = 0)
- {
- if (width <= 0 || TextLength <= width)
- return [this];
-
- var results = new List();
- var currentSegments = new List();
- int col = 0;
-
- foreach (var segment in Segments)
- {
- var text = segment.Text;
- int chunkStart = 0;
- int charPos = 0;
-
- foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
- {
- var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
-
- if (col + graphemeCols > width)
- {
- if (charPos > chunkStart)
- currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
-
- results.Add(new ChatLine(currentSegments));
- currentSegments = [];
-
- if (continuationIndent > 0)
- {
- currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
- col = continuationIndent;
- }
- else
- {
- col = 0;
- }
-
- chunkStart = charPos;
- }
-
- col += graphemeCols;
- charPos += grapheme.Length;
- }
-
- if (chunkStart < text.Length)
- currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
- }
-
- if (currentSegments.Count > 0)
- results.Add(new ChatLine(currentSegments));
-
- // Propagate attachment/type metadata to all wrapped lines so they remain clickable
- foreach (var wrapped in results)
- {
- wrapped.AttachmentUrl = AttachmentUrl;
- wrapped.AttachmentFileName = AttachmentFileName;
- wrapped.Type = Type;
- wrapped.MessageId = MessageId;
- }
-
- return results;
- }
-
- ///
- /// Returns true if a line contains printable color tags.
- ///
- public static bool HasColorTags(string text) =>
- text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
-
- ///
- /// Remove all color tags from text, returning only the visible characters.
- ///
- public static string StripColorTags(string text) =>
- ColorTagRegex().Replace(text, "");
-
- ///
- /// Parse a string containing printable color tags into colored segments.
- /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
- ///
- public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
- {
- var segments = new List();
- int lastIndex = 0;
- Color? currentFg = null;
- Color? currentBg = null;
- var defaultFg = defaultAttr?.Foreground;
- var defaultBg = defaultAttr?.Background ?? Color.None;
-
- Attribute? BuildAttr()
- {
- if (currentFg is null && currentBg is null) return defaultAttr;
- var fg = currentFg ?? defaultFg ?? Color.White;
- var bg = currentBg ?? defaultBg;
- return new Attribute(fg, bg);
- }
-
- foreach (Match match in ColorTagRegex().Matches(text))
- {
- if (match.Index > lastIndex)
- {
- var t = text[lastIndex..match.Index];
- if (t.Length > 0)
- segments.Add(new ChatSegment(t, BuildAttr()));
- }
-
- if (match.Groups[1].Success)
- {
- currentFg = null;
- currentBg = null;
- }
- else if (match.Groups[2].Success)
- {
- var hex = match.Groups[3].Value;
- var r = Convert.ToInt32(hex[..2], 16);
- var g = Convert.ToInt32(hex[2..4], 16);
- var b = Convert.ToInt32(hex[4..6], 16);
- if (match.Groups[2].Value == "F")
- currentFg = new Color(r, g, b);
- else
- currentBg = new Color(r, g, b);
- }
-
- lastIndex = match.Index + match.Length;
- }
-
- if (lastIndex < text.Length)
- {
- var t = text[lastIndex..];
- if (t.Length > 0)
- segments.Add(new ChatSegment(t, BuildAttr()));
- }
-
- return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
- }
-
- [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
- private static partial Regex ColorTagRegex();
-}
-
-///
-/// Custom list data source for chat messages with per-segment coloring.
-///
-public class ChatListSource : IListDataSource
-{
- private readonly List _lines = [];
-
- public event NotifyCollectionChangedEventHandler? CollectionChanged;
-
- public int Count => _lines.Count;
- public int MaxItemLength { get; private set; }
- public bool SuspendCollectionChangedEvent { get; set; }
-
- public void Add(ChatLine line)
- {
- _lines.Add(line);
- UpdateMaxLength(line);
- RaiseCollectionChanged();
- }
-
- public void AddRange(IEnumerable lines)
- {
- foreach (var line in lines)
- {
- _lines.Add(line);
- UpdateMaxLength(line);
- }
- RaiseCollectionChanged();
- }
-
- public void InsertRange(int index, IEnumerable lines)
- {
- var items = lines.ToList();
- _lines.InsertRange(index, items);
- foreach (var line in items)
- UpdateMaxLength(line);
- RaiseCollectionChanged();
- }
-
- public void Clear()
- {
- _lines.Clear();
- MaxItemLength = 0;
- RaiseCollectionChanged();
- }
-
- public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
-
- public bool IsMarked(int item) => false;
- public void SetMark(int item, bool value) { }
- public IList ToList() => _lines.Select(l => l.ToString()).ToList();
-
- public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
- {
- listView.Move(Math.Max(col - viewportX, 0), row);
-
- var chatLine = _lines[item];
- var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
- var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
-
- int charPos = 0;
- int drawnChars = 0;
-
- foreach (var segment in chatLine.Segments)
- {
- var attr = segment.Color ?? normalAttr;
- if (attr.Background == Color.None)
- attr = attr with { Background = normalAttr.Background };
- if (mentionBg.HasValue)
- attr = attr with { Background = mentionBg.Value };
- listView.SetAttribute(attr);
-
- foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
- {
- var cols = Math.Max(grapheme.GetColumns(), 1);
- if (charPos >= viewportX && drawnChars + cols <= width)
- {
- listView.AddStr(grapheme);
- drawnChars += cols;
- }
- charPos += cols;
- }
- }
-
- var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
- listView.SetAttribute(fillAttr);
- for (int i = drawnChars; i < width; i++)
- listView.AddStr(" ");
- }
-
- private void UpdateMaxLength(ChatLine line)
- {
- if (line.TextLength > MaxItemLength)
- MaxItemLength = line.TextLength;
- }
-
- private void RaiseCollectionChanged()
- {
- if (!SuspendCollectionChangedEvent)
- CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
- }
-
- public void Dispose() { }
-}
-
-///
-/// Custom list data source for colored channel list rendering.
-/// Active channel gets a > indicator, unread channels are bright with a count badge.
-///
-public class ChannelListSource : IListDataSource
-{
- private readonly List _channelNames = [];
- private readonly Dictionary _unreadCounts = [];
- private string _activeChannel = string.Empty;
-
- public event NotifyCollectionChangedEventHandler? CollectionChanged;
- public int Count => _channelNames.Count;
- public int MaxItemLength { get; private set; }
- public bool SuspendCollectionChangedEvent { get; set; }
-
- private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
- private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
- private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
- private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
-
- public void Update(List channels, Dictionary unread, string activeChannel)
- {
- _channelNames.Clear();
- _channelNames.AddRange(channels);
- _unreadCounts.Clear();
- foreach (var kv in unread)
- _unreadCounts[kv.Key] = kv.Value;
- _activeChannel = activeChannel;
- MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
- if (!SuspendCollectionChangedEvent)
- CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
- }
-
- public bool IsMarked(int item) => false;
- public void SetMark(int item, bool value) { }
- public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
-
- public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
- {
- listView.Move(Math.Max(col - viewportX, 0), row);
-
- var name = _channelNames[item];
- var isActive = name == _activeChannel;
- _unreadCounts.TryGetValue(name, out var unread);
- var hasUnread = unread > 0;
-
- var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
- var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
- var prefix = isActive ? "> " : " ";
- var channelText = $"#{name}";
- var badge = hasUnread ? $" ({unread})" : "";
-
- // Resolve Transparent backgrounds to the view's actual background
- Attribute Resolve(Attribute attr) =>
- attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
-
- int drawnChars = 0;
-
- if (selected)
- {
- listView.SetAttribute(focusAttr);
- drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
- }
- else
- {
- listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
- drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
-
- listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
- drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
-
- if (hasUnread)
- {
- listView.SetAttribute(Resolve(BadgeAttr));
- drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
- }
- }
-
- var fillAttr = selected ? focusAttr : normalAttr;
- listView.SetAttribute(fillAttr);
- for (int i = drawnChars; i < width; i++)
- listView.AddStr(" ");
- }
-
- public void Dispose() { }
-}
-
-///
-/// Custom list data source for the online users panel with per-user nickname colors.
-///
-public class UserListSource : IListDataSource
-{
- private readonly List<(string Text, Attribute? NameColor)> _users = [];
-
- public event NotifyCollectionChangedEventHandler? CollectionChanged;
- public int Count => _users.Count;
- public int MaxItemLength { get; private set; }
- public bool SuspendCollectionChangedEvent { get; set; }
-
- public void Update(List<(string Text, Attribute? NameColor)> users)
- {
- _users.Clear();
- _users.AddRange(users);
- MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
- if (!SuspendCollectionChangedEvent)
- CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
- }
-
- public bool IsMarked(int item) => false;
- public void SetMark(int item, bool value) { }
- public IList ToList() => _users.Select(u => u.Text).ToList();
-
- public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
- {
- listView.Move(Math.Max(col - viewportX, 0), row);
-
- var (text, nameColor) = _users[item];
- var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
-
- // Find where the name starts (after status icon + space + optional role badge)
- // Format: "● ★Username" or "● Username"
- var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
- int nameStart = 0;
- while (nameStart < graphemes.Count)
- {
- var g = graphemes[nameStart];
- if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
- break;
- nameStart++;
- }
-
- int drawnChars = 0;
-
- // Draw prefix (status icon + role badge) in normal color
- listView.SetAttribute(normalAttr);
- for (int i = 0; i < nameStart; i++)
- {
- var cols = Math.Max(graphemes[i].GetColumns(), 1);
- if (drawnChars + cols > width) break;
- listView.AddStr(graphemes[i]);
- drawnChars += cols;
- }
-
- // Draw name in nickname color
- var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
- listView.SetAttribute(userAttr);
- for (int i = nameStart; i < graphemes.Count; i++)
- {
- var cols = Math.Max(graphemes[i].GetColumns(), 1);
- if (drawnChars + cols > width) break;
- listView.AddStr(graphemes[i]);
- drawnChars += cols;
- }
-
- // Fill rest
- listView.SetAttribute(normalAttr);
- for (int i = drawnChars; i < width; i++)
- listView.AddStr(" ");
- }
-
- public void Dispose() { }
-}
-
-///
-/// Shared rendering helpers for IListDataSource implementations.
-///
-static class RenderHelpers
-{
- ///
- /// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
- /// Returns the updated drawn-columns count.
- ///
- public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
- {
- foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
- {
- var cols = Math.Max(grapheme.GetColumns(), 1);
- if (drawn + cols > maxWidth) break;
- lv.AddStr(grapheme);
- drawn += cols;
- }
- return drawn;
- }
-}
-
-///
-/// Shared color attributes for chat rendering (timestamps, system messages).
-///
-public static partial class ChatColors
-{
- public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
- public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
- public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
- public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
- public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
- public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
- public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
- public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
- public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
- public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
-
- ///
- /// Split text around @mentions, giving each @word the MentionTextAttr accent color.
- /// Non-mention text uses the provided default color.
- ///
- public static List SplitMentions(string text, Attribute? defaultColor = null)
- {
- var segments = new List();
- int lastIndex = 0;
-
- foreach (Match match in MentionRegex().Matches(text))
- {
- if (match.Index > lastIndex)
- segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
-
- segments.Add(new ChatSegment(match.Value, MentionTextAttr));
- lastIndex = match.Index + match.Length;
- }
-
- if (lastIndex < text.Length)
- segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
-
- return segments;
- }
-
- [GeneratedRegex(@"@[\w-]+")]
- private static partial Regex MentionRegex();
-}
-
-///
-/// Helper to parse hex colors to Terminal.Gui Attributes.
-///
-public static class ColorHelper
-{
- public static Attribute? ParseHexColor(string? hex)
- {
- if (string.IsNullOrWhiteSpace(hex))
- return null;
-
- hex = hex.TrimStart('#');
- if (hex.Length != 6)
- return null;
-
- try
- {
- var r = Convert.ToInt32(hex[..2], 16);
- var g = Convert.ToInt32(hex[2..4], 16);
- var b = Convert.ToInt32(hex[4..6], 16);
- return new Attribute(new Color(r, g, b), Color.None);
- }
- catch
- {
- return null;
- }
- }
-}
diff --git a/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs b/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs
index 448a705..7c8e81c 100644
--- a/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs
@@ -5,7 +5,7 @@ using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
public sealed class AudioPlayerDialog
{
diff --git a/src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs b/src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs
index a3f770e..98b0b71 100644
--- a/src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs
@@ -4,7 +4,7 @@ using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
///
/// Result returned from the connect dialog.
diff --git a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs
index 8c407b6..a00dd5b 100644
--- a/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
diff --git a/src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs b/src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs
index 0cbc2c1..7149808 100644
--- a/src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs
@@ -1,10 +1,11 @@
+using EchoHub.Client.UI.Helpers;
using Terminal.Gui.App;
-using Terminal.Gui.Views;
-using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
///
/// Result returned from the profile edit dialog.
@@ -243,32 +244,13 @@ public sealed class ProfileEditDialog
return;
}
- var color = ParseHexToTrueColor(hexColor.Trim());
+ var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
preview.SetScheme(new Scheme
{
Normal = new Attribute(color, Color.Blue)
});
}
- ///
- /// Parses a hex color string to a Terminal.Gui TrueColor Color.
- /// V2 supports TrueColor via new Color(r, g, b).
- ///
- 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;
}
diff --git a/src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs b/src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs
index 8e14631..a098d23 100644
--- a/src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs
@@ -1,12 +1,14 @@
-using Terminal.Gui.App;
-using Terminal.Gui.Views;
-using Terminal.Gui.ViewBase;
-using Terminal.Gui.Drawing;
+using EchoHub.Client.UI.Chat;
+using EchoHub.Client.UI.Helpers;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
+using Terminal.Gui.App;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.ViewBase;
+using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
///
/// Action selected by the user in their own profile dialog.
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
// Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
- if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
+ if (HexColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue);
row++;
diff --git a/src/EchoHub.Client/UI/Dialogs/StatusDialog.cs b/src/EchoHub.Client/UI/Dialogs/StatusDialog.cs
index acd1653..5b3f6f9 100644
--- a/src/EchoHub.Client/UI/Dialogs/StatusDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/StatusDialog.cs
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using EchoHub.Core.Models;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
///
/// Result returned from the status dialog.
diff --git a/src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs b/src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs
index d305377..c3b1e0a 100644
--- a/src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
public sealed class UpdateConfirmDialog
{
diff --git a/src/EchoHub.Client/UI/Dialogs/UpdateProgressDialog.cs b/src/EchoHub.Client/UI/Dialogs/UpdateProgressDialog.cs
index 1d71f04..83111a0 100644
--- a/src/EchoHub.Client/UI/Dialogs/UpdateProgressDialog.cs
+++ b/src/EchoHub.Client/UI/Dialogs/UpdateProgressDialog.cs
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Dialogs;
public sealed class UpdateProgressDialog
{
diff --git a/src/EchoHub.Client/UI/EmojiHelper.cs b/src/EchoHub.Client/UI/Helpers/EmojiHelper.cs
similarity index 99%
rename from src/EchoHub.Client/UI/EmojiHelper.cs
rename to src/EchoHub.Client/UI/Helpers/EmojiHelper.cs
index c1d794a..16f3026 100644
--- a/src/EchoHub.Client/UI/EmojiHelper.cs
+++ b/src/EchoHub.Client/UI/Helpers/EmojiHelper.cs
@@ -1,7 +1,7 @@
using System.Globalization;
using System.Text;
-namespace EchoHub.Client.UI;
+namespace EchoHub.Client.UI.Helpers;
///
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
diff --git a/src/EchoHub.Client/UI/Helpers/HexColorHelper.cs b/src/EchoHub.Client/UI/Helpers/HexColorHelper.cs
new file mode 100644
index 0000000..2052d3e
--- /dev/null
+++ b/src/EchoHub.Client/UI/Helpers/HexColorHelper.cs
@@ -0,0 +1,58 @@
+using Terminal.Gui.Drawing;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.Helpers;
+
+///
+/// Helper to parse hex colors to Terminal.Gui Attributes.
+///
+public static class HexColorHelper
+{
+ public static Attribute? ParseHexColor(string? hex)
+ {
+ if (string.IsNullOrWhiteSpace(hex))
+ return null;
+
+ hex = hex.TrimStart('#');
+ if (hex.Length != 6)
+ return null;
+
+ try
+ {
+ var r = Convert.ToInt32(hex[..2], 16);
+ var g = Convert.ToInt32(hex[2..4], 16);
+ var b = Convert.ToInt32(hex[4..6], 16);
+ return new Attribute(new Color(r, g, b), Color.None);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Parse a hex color string (with or without #) to a Terminal.Gui Color.
+ /// Returns if parsing fails.
+ ///
+ public static Color ParseHexToColor(string? hex, Color fallback = default)
+ {
+ if (string.IsNullOrWhiteSpace(hex))
+ return fallback;
+
+ var trimmed = hex.TrimStart('#');
+ if (trimmed.Length != 6)
+ return fallback;
+
+ try
+ {
+ var r = Convert.ToInt32(trimmed[..2], 16);
+ var g = Convert.ToInt32(trimmed[2..4], 16);
+ var b = Convert.ToInt32(trimmed[4..6], 16);
+ return new Color(r, g, b);
+ }
+ catch
+ {
+ return fallback;
+ }
+ }
+}
diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs
new file mode 100644
index 0000000..a54a99c
--- /dev/null
+++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs
@@ -0,0 +1,96 @@
+using System.Collections;
+using System.Collections.Specialized;
+using EchoHub.Client.UI.Chat;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.Views;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.ListSources;
+
+///
+/// Custom list data source for colored channel list rendering.
+/// Active channel gets a > indicator, unread channels are bright with a count badge.
+///
+public class ChannelListSource : IListDataSource
+{
+ private readonly List _channelNames = [];
+ private readonly Dictionary _unreadCounts = [];
+ private string _activeChannel = string.Empty;
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+ public int Count => _channelNames.Count;
+ public int MaxItemLength { get; private set; }
+ public bool SuspendCollectionChangedEvent { get; set; }
+
+ private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
+ private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
+ private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
+ private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
+
+ public void Update(List channels, Dictionary unread, string activeChannel)
+ {
+ _channelNames.Clear();
+ _channelNames.AddRange(channels);
+ _unreadCounts.Clear();
+ foreach (var kv in unread)
+ _unreadCounts[kv.Key] = kv.Value;
+ _activeChannel = activeChannel;
+ MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
+ if (!SuspendCollectionChangedEvent)
+ CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ public bool IsMarked(int item) => false;
+ public void SetMark(int item, bool value) { }
+ public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
+
+ public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
+ {
+ listView.Move(Math.Max(col - viewportX, 0), row);
+
+ var name = _channelNames[item];
+ var isActive = name == _activeChannel;
+ _unreadCounts.TryGetValue(name, out var unread);
+ var hasUnread = unread > 0;
+
+ var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
+ var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
+ var prefix = isActive ? "> " : " ";
+ var channelText = $"#{name}";
+ var badge = hasUnread ? $" ({unread})" : "";
+
+ // Resolve Transparent backgrounds to the view's actual background
+ Attribute Resolve(Attribute attr) =>
+ attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
+
+ int drawnChars = 0;
+
+ if (selected)
+ {
+ listView.SetAttribute(focusAttr);
+ drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
+ }
+ else
+ {
+ listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
+ drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
+
+ listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
+ drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
+
+ if (hasUnread)
+ {
+ listView.SetAttribute(Resolve(BadgeAttr));
+ drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
+ }
+ }
+
+ var fillAttr = selected ? focusAttr : normalAttr;
+ listView.SetAttribute(fillAttr);
+ for (int i = drawnChars; i < width; i++)
+ listView.AddStr(" ");
+ }
+
+ public void Dispose() { }
+}
diff --git a/src/EchoHub.Client/UI/ListSources/UserListSource.cs b/src/EchoHub.Client/UI/ListSources/UserListSource.cs
new file mode 100644
index 0000000..50075b2
--- /dev/null
+++ b/src/EchoHub.Client/UI/ListSources/UserListSource.cs
@@ -0,0 +1,84 @@
+using System.Collections;
+using System.Collections.Specialized;
+using Terminal.Gui.Drawing;
+using Terminal.Gui.Text;
+using Terminal.Gui.Views;
+using Attribute = Terminal.Gui.Drawing.Attribute;
+
+namespace EchoHub.Client.UI.ListSources;
+
+///
+/// Custom list data source for the online users panel with per-user nickname colors.
+///
+public class UserListSource : IListDataSource
+{
+ private readonly List<(string Text, Attribute? NameColor)> _users = [];
+
+ public event NotifyCollectionChangedEventHandler? CollectionChanged;
+ public int Count => _users.Count;
+ public int MaxItemLength { get; private set; }
+ public bool SuspendCollectionChangedEvent { get; set; }
+
+ public void Update(List<(string Text, Attribute? NameColor)> users)
+ {
+ _users.Clear();
+ _users.AddRange(users);
+ MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
+ if (!SuspendCollectionChangedEvent)
+ CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ public bool IsMarked(int item) => false;
+ public void SetMark(int item, bool value) { }
+ public IList ToList() => _users.Select(u => u.Text).ToList();
+
+ public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
+ {
+ listView.Move(Math.Max(col - viewportX, 0), row);
+
+ var (text, nameColor) = _users[item];
+ var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
+
+ // Find where the name starts (after status icon + space + optional role badge)
+ // Format: "● ★Username" or "● Username"
+ var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
+ int nameStart = 0;
+ while (nameStart < graphemes.Count)
+ {
+ var g = graphemes[nameStart];
+ if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
+ break;
+ nameStart++;
+ }
+
+ int drawnChars = 0;
+
+ // Draw prefix (status icon + role badge) in normal color
+ listView.SetAttribute(normalAttr);
+ for (int i = 0; i < nameStart; i++)
+ {
+ var cols = Math.Max(graphemes[i].GetColumns(), 1);
+ if (drawnChars + cols > width) break;
+ listView.AddStr(graphemes[i]);
+ drawnChars += cols;
+ }
+
+ // Draw name in nickname color
+ var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
+ listView.SetAttribute(userAttr);
+ for (int i = nameStart; i < graphemes.Count; i++)
+ {
+ var cols = Math.Max(graphemes[i].GetColumns(), 1);
+ if (drawnChars + cols > width) break;
+ listView.AddStr(graphemes[i]);
+ drawnChars += cols;
+ }
+
+ // Fill rest
+ listView.SetAttribute(normalAttr);
+ for (int i = drawnChars; i < width; i++)
+ listView.AddStr(" ");
+ }
+
+ public void Dispose() { }
+}
diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs
index fdd4000..5e0791e 100644
--- a/src/EchoHub.Client/UI/MainWindow.cs
+++ b/src/EchoHub.Client/UI/MainWindow.cs
@@ -1,5 +1,7 @@
-using System.Text.RegularExpressions;
using EchoHub.Client.Themes;
+using EchoHub.Client.UI.Chat;
+using EchoHub.Client.UI.Helpers;
+using EchoHub.Client.UI.ListSources;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Terminal.Gui.App;
@@ -55,13 +57,10 @@ public sealed class MainWindow : Runnable
];
private readonly List _channelNames = [];
- private readonly Dictionary> _channelMessages = [];
- private readonly Dictionary _channelUnread = [];
private readonly Dictionary _channelTopics = [];
private readonly Dictionary _channelPublic = [];
private readonly ChannelListSource _channelListSource;
- private string _currentChannel = string.Empty;
- private string _currentUser = string.Empty;
+ private readonly ChatMessageManager _messageManager;
private string _connectionStatus = "Disconnected";
private int _lastChatWidth;
@@ -130,9 +129,11 @@ public sealed class MainWindow : Runnable
///
public event Action? OnFileDownloadRequested;
- public MainWindow(IApplication app)
+ public MainWindow(IApplication app, ChatMessageManager messageManager)
{
_app = app;
+ _messageManager = messageManager;
+ _messageManager.MessagesChanged += OnMessagesChanged;
Arrangement = ViewArrangement.Fixed;
// Menu bar at the top
@@ -372,7 +373,7 @@ public sealed class MainWindow : Runnable
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
{
var channelName = _channelNames[index.Value];
- if (channelName != _currentChannel)
+ if (channelName != _messageManager.CurrentChannel)
{
SwitchToChannel(channelName);
OnChannelSelected?.Invoke(channelName);
@@ -420,9 +421,9 @@ public sealed class MainWindow : Runnable
else if (e.KeyCode == EnterKey.KeyCode)
{
var text = _inputField.Text?.Trim() ?? string.Empty;
- if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
+ if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
{
- OnMessageSubmitted?.Invoke(_currentChannel, text);
+ OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
_inputField.Text = string.Empty;
}
e.Handled = true;
@@ -499,6 +500,7 @@ public sealed class MainWindow : Runnable
if (newWidth > 0 && newWidth != _lastChatWidth)
{
_lastChatWidth = newWidth;
+ _messageManager.SetChatWidth(newWidth);
RefreshMessages();
}
}
@@ -517,125 +519,12 @@ public sealed class MainWindow : Runnable
}
}
- ///
- /// Add a message to the specified channel's message list and refresh if it is the current channel.
- /// Tracks unread count for non-active channels.
- ///
- public void AddMessage(MessageDto message)
+ private void OnMessagesChanged(string channelName)
{
- 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)
- {
+ if (channelName == _messageManager.CurrentChannel)
RefreshMessages();
- }
else
- {
- // Increment unread count for non-active channels
- _channelUnread.TryGetValue(message.ChannelName, out var count);
- _channelUnread[message.ChannelName] = count + 1;
RefreshChannelList();
- }
- }
-
- ///
- /// Add a system/informational message to a channel with colored styling.
- ///
- public void AddSystemMessage(string channelName, string text)
- {
- if (!_channelMessages.TryGetValue(channelName, out var messages))
- {
- messages = [];
- _channelMessages[channelName] = messages;
- }
-
- var time = DateTimeOffset.Now.ToString("HH:mm");
- var textLines = text.Split('\n');
-
- // First line gets timestamp prefix
- messages.Add(new ChatLine(
- [
- new($"[{time}] ", ChatColors.TimestampAttr),
- new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
- ]));
-
- // Continuation lines are indented to align
- var indent = new string(' ', $"[{time}] ** ".Length);
- for (int i = 1; i < textLines.Length; i++)
- {
- var line = textLines[i].TrimEnd('\r');
- if (string.IsNullOrWhiteSpace(line)) continue;
- messages.Add(new ChatLine(
- [
- new($"{indent}{line}", ChatColors.SystemAttr)
- ]));
- }
-
- if (channelName == _currentChannel)
- {
- RefreshMessages();
- }
- }
-
- ///
- /// Add a status change message to a channel with colored styling.
- ///
- public void AddStatusMessage(string channelName, string username, string status)
- {
- var time = DateTimeOffset.Now.ToString("HH:mm");
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new($"** {username} is now {status}", ChatColors.SystemAttr)
- };
-
- if (!_channelMessages.TryGetValue(channelName, out var messages))
- {
- messages = [];
- _channelMessages[channelName] = messages;
- }
- messages.Add(new ChatLine(segments));
-
- if (channelName == _currentChannel)
- {
- RefreshMessages();
- }
- }
-
- ///
- /// Remove all lines associated with a specific message ID.
- ///
- public void RemoveMessage(string channelName, Guid messageId)
- {
- if (_channelMessages.TryGetValue(channelName, out var messages))
- {
- messages.RemoveAll(l => l.MessageId == messageId);
- if (channelName == _currentChannel)
- RefreshMessages();
- }
- }
-
- ///
- /// Clear all messages from a specific channel.
- ///
- public void ClearChannelMessages(string channelName)
- {
- if (_channelMessages.TryGetValue(channelName, out var messages))
- {
- messages.Clear();
- if (channelName == _currentChannel)
- RefreshMessages();
- }
}
///
@@ -651,8 +540,6 @@ public sealed class MainWindow : Runnable
_channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic;
_channelPublic[ch.Name] = ch.IsPublic;
- if (!_channelMessages.ContainsKey(ch.Name))
- _channelMessages[ch.Name] = [];
}
RefreshChannelList();
}
@@ -669,8 +556,6 @@ public sealed class MainWindow : Runnable
return;
_channelNames.Add(channelName);
- if (!_channelMessages.ContainsKey(channelName))
- _channelMessages[channelName] = [];
RefreshChannelList();
}
@@ -691,7 +576,7 @@ public sealed class MainWindow : Runnable
public void SetChannelTopic(string channelName, string? topic)
{
_channelTopics[channelName] = topic;
- if (channelName == _currentChannel)
+ if (channelName == _messageManager.CurrentChannel)
UpdateTopicBar();
}
@@ -757,15 +642,17 @@ public sealed class MainWindow : Runnable
Write(_connectionStatus, Resolve(statusAttr));
// User
- if (!string.IsNullOrEmpty(_currentUser))
- Write($" \u2502 User: {_currentUser}", normalAttr);
+ var currentUser = _messageManager.CurrentUser;
+ if (!string.IsNullOrEmpty(currentUser))
+ Write($" \u2502 User: {currentUser}", normalAttr);
// Channel + type
- if (!string.IsNullOrEmpty(_currentChannel))
+ var currentChannel = _messageManager.CurrentChannel;
+ if (!string.IsNullOrEmpty(currentChannel))
{
- _channelPublic.TryGetValue(_currentChannel, out var isPublic);
+ _channelPublic.TryGetValue(currentChannel, out var isPublic);
var typeSuffix = isPublic ? "public" : "private";
- Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr);
+ Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
}
// Fill remaining space
@@ -781,17 +668,17 @@ public sealed class MainWindow : Runnable
}
///
- /// Set the current user name for display in the status bar.
+ /// Set the current user name (delegates to message manager for @mention detection).
///
public void SetCurrentUser(string username)
{
- _currentUser = username;
+ _messageManager.SetCurrentUser(username);
}
///
/// Get the current channel name.
///
- public string CurrentChannel => _currentChannel;
+ public string CurrentChannel => _messageManager.CurrentChannel;
///
/// Get all channel names that have message buffers (for broadcasting status changes).
@@ -803,11 +690,10 @@ public sealed class MainWindow : Runnable
///
public void SwitchToChannel(string channelName)
{
- _currentChannel = channelName;
+ _messageManager.CurrentChannel = channelName;
_chatFrame.Title = $"#{channelName}";
- // Reset unread count for this channel
- _channelUnread[channelName] = 0;
+ _messageManager.ClearUnread(channelName);
RefreshChannelList();
RefreshMessages();
@@ -820,33 +706,15 @@ public sealed class MainWindow : Runnable
_channelList.SelectedItem = idx;
}
- ///
- /// Load historical messages into a channel, replacing any existing messages.
- ///
- public void LoadHistory(string channelName, List messages)
- {
- var formatted = messages.SelectMany(FormatMessage).ToList();
- _channelMessages[channelName] = formatted;
-
- if (channelName == _currentChannel)
- {
- RefreshMessages();
- }
- }
-
-
///
/// Clear all messages and channels (used on disconnect).
///
public void ClearAll()
{
_channelNames.Clear();
- _channelMessages.Clear();
- _channelUnread.Clear();
+ _messageManager.ClearAll();
_channelTopics.Clear();
_channelPublic.Clear();
- _currentChannel = string.Empty;
- _currentUser = string.Empty;
_channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat";
@@ -868,7 +736,8 @@ public sealed class MainWindow : Runnable
private void RefreshMessages()
{
- if (_channelMessages.TryGetValue(_currentChannel, out var messages))
+ var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
+ if (messages is not null)
{
var width = _messageList.Viewport.Width;
@@ -906,11 +775,11 @@ public sealed class MainWindow : Runnable
///
private void RefreshChannelList()
{
- _channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
+ _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
_channelList.Source = _channelListSource;
// Restore selection to current channel
- var idx = _channelNames.IndexOf(_currentChannel);
+ var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
if (idx >= 0)
_channelList.SelectedItem = idx;
}
@@ -920,7 +789,7 @@ public sealed class MainWindow : Runnable
///
private void UpdateTopicBar()
{
- _channelTopics.TryGetValue(_currentChannel, out var topic);
+ _channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
if (!string.IsNullOrWhiteSpace(topic))
{
_topicLabel.Text = $" Topic: {topic}";
@@ -980,7 +849,7 @@ public sealed class MainWindow : Runnable
_ => ""
};
var text = $"{statusIcon} {roleTag}{name}";
- var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
+ var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
}).ToList();
@@ -989,234 +858,4 @@ public sealed class MainWindow : Runnable
_usersFrame.Title = $"Users ({users.Count})";
}
- ///
- /// Format a message DTO into one or more display lines based on its MessageType.
- /// Timestamps are dimmed and sender names are colored.
- ///
- private List FormatMessage(MessageDto message)
- {
- var time = message.SentAt.ToLocalTime().ToString("HH:mm");
- var senderName = message.SenderUsername + ":";
- var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor);
-
- var lines = new List();
-
- switch (message.Type)
- {
- case MessageType.Image:
- lines.Add(BuildChatLine(time, senderName, senderColor, " [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'))
- {
- // Parse color tags from colored ASCII art
- var trimmed = artLine.TrimEnd('\r');
- if (ChatLine.HasColorTags(trimmed))
- lines.Add(ChatLine.FromColoredText(" " + trimmed));
- else
- lines.Add(new ChatLine($" {trimmed}"));
- }
- }
- break;
-
- case MessageType.Audio:
- var audioName = message.AttachmentFileName ?? "unknown";
- var audioSize = FormatFileSize(message.AttachmentFileSize);
- var audioLine = BuildChatLineColored(time, senderName, senderColor,
- $" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
- audioLine.AttachmentUrl = message.AttachmentUrl;
- audioLine.AttachmentFileName = audioName;
- audioLine.Type = MessageType.Audio;
- lines.Add(audioLine);
- break;
-
- case MessageType.File:
- var fileName = message.AttachmentFileName ?? "unknown";
- var fileSize = FormatFileSize(message.AttachmentFileSize);
- var fileLine = BuildChatLineColored(time, senderName, senderColor,
- $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
- fileLine.AttachmentUrl = message.AttachmentUrl;
- fileLine.AttachmentFileName = fileName;
- fileLine.Type = MessageType.File;
- lines.Add(fileLine);
- break;
-
- case MessageType.Text:
- default:
- var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
- var contentLines = displayContent.Split('\n');
- var firstLine = contentLines[0].TrimEnd('\r');
- lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
- // Continuation lines indented to align with first line's content
- var indent = new string(' ', $"[{time}] {senderName} ".Length);
- for (int i = 1; i < contentLines.Length; i++)
- {
- var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
- lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
- }
-
- // Render link embeds if present
- if (message.Embeds is { Count: > 0 })
- {
- var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
- foreach (var embed in message.Embeds)
- lines.AddRange(FormatEmbed(embed, indent, chatWidth));
- }
- break;
- }
-
- // Tag all lines with the message ID for deletion support
- foreach (var line in lines)
- line.MessageId = message.Id;
-
- // Check for @mention of current user
- if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
- {
- var pattern = $@"@{Regex.Escape(_currentUser)}\b";
- if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
- {
- foreach (var line in lines)
- line.IsMention = true;
- }
- }
-
- return lines;
- }
-
- ///
- /// Build a chat line with a dimmed timestamp and optionally colored sender name.
- ///
- private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
- {
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
- new(suffix, null)
- };
- return new ChatLine(segments);
- }
-
- ///
- /// Build a chat line with a colored suffix (used for audio/file indicators).
- ///
- private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
- {
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
- new(suffix, suffixColor)
- };
- return new ChatLine(segments);
- }
-
- ///
- /// Build a chat line with @mention highlighting in the suffix text.
- ///
- private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
- {
- var segments = new List
- {
- new($"[{time}] ", ChatColors.TimestampAttr),
- new(senderName, senderColor),
- };
- segments.AddRange(ChatColors.SplitMentions(suffix));
- return new ChatLine(segments);
- }
-
- ///
- /// Format a link embed as indented chat lines with a left border bar,
- /// text at full width, and optional icon below (preview image).
- /// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
- ///
- private static List FormatEmbed(EmbedDto embed, string indent, int chatWidth)
- {
- var lines = new List();
- const string border = "\u258f "; // ▏ + space
- const int borderCols = 2;
- int indentCols = indent.GetColumns();
- int textWidth = chatWidth - indentCols - borderCols;
- if (textWidth < 20) textWidth = 20;
-
- // Helper: create a bordered text line
- void AddTextLine(string text, Attribute? color)
- {
- lines.Add(new ChatLine(
- [
- new ChatSegment(indent, null),
- new ChatSegment(border, ChatColors.EmbedBorderAttr),
- new ChatSegment(text, color)
- ]));
- }
-
- // Site name
- if (!string.IsNullOrWhiteSpace(embed.SiteName))
- AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
-
- // Title
- if (!string.IsNullOrWhiteSpace(embed.Title))
- {
- foreach (var wrapped in WordWrap(embed.Title, textWidth))
- AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
- }
-
- // Description (word-wrapped at full available width)
- if (!string.IsNullOrWhiteSpace(embed.Description))
- {
- foreach (var wrapped in WordWrap(embed.Description, textWidth))
- AddTextLine(wrapped, ChatColors.EmbedDescAttr);
- }
-
- return lines;
- }
-
- ///
- /// Simple word-wrap: splits text into lines that fit within maxCols display columns.
- ///
- private static List WordWrap(string text, int maxCols)
- {
- if (maxCols <= 0)
- return [text];
-
- var result = new List();
- var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- var currentLine = "";
-
- foreach (var word in words)
- {
- var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
- if (candidate.GetColumns() <= maxCols)
- {
- currentLine = candidate;
- }
- else
- {
- if (currentLine.Length > 0)
- result.Add(currentLine);
- // If a single word exceeds maxCols, just add it as-is
- currentLine = word;
- }
- }
-
- if (currentLine.Length > 0)
- result.Add(currentLine);
-
- return result;
- }
-
- private static string FormatFileSize(long? bytes)
- {
- if (bytes is null or 0)
- return "?";
-
- return bytes.Value switch
- {
- < 1024 => $"{bytes.Value} B",
- < 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
- < 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
- _ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
- };
- }
}
diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj
index 9795f48..b2540f3 100644
--- a/src/EchoHub.Server/EchoHub.Server.csproj
+++ b/src/EchoHub.Server/EchoHub.Server.csproj
@@ -1,9 +1,5 @@
-
-
-
-
@@ -27,7 +23,7 @@
enable
enable
- hue_icon.ico
+ ..\EchoHub.Client\Assets\hue_icon.ico
diff --git a/src/EchoHub.Tests/ChatLineTests.cs b/src/EchoHub.Tests/ChatLineTests.cs
index 16abab5..1d8f556 100644
--- a/src/EchoHub.Tests/ChatLineTests.cs
+++ b/src/EchoHub.Tests/ChatLineTests.cs
@@ -1,4 +1,4 @@
-using EchoHub.Client.UI;
+using EchoHub.Client.UI.Chat;
using Xunit;
namespace EchoHub.Tests;