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;