From a65b41fb7f9a79b80cbebff7069cd8e51edbced1 Mon Sep 17 00:00:00 2001 From: HueByte Date: Sat, 21 Feb 2026 16:02:49 +0100 Subject: [PATCH] feat: enhance channel management and status bar functionality --- docs/changelog/v0.2.5.md | 12 +++ src/EchoHub.Client/AppOrchestrator.cs | 18 +++- src/EchoHub.Client/UI/MainWindow.cs | 90 +++++++++++++++++-- .../Controllers/ChannelsController.cs | 13 +++ src/EchoHub.Server/Services/ChatService.cs | 21 ++++- src/Terminal.Gui | 2 +- 6 files changed, 147 insertions(+), 9 deletions(-) diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md index 9f3818c..adf5cd2 100644 --- a/docs/changelog/v0.2.5.md +++ b/docs/changelog/v0.2.5.md @@ -37,6 +37,18 @@ - Dialogs retain solid `DarkGray` background for readability - Uses local Terminal.Gui fork (submodule) with transparent color support pending upstream merge ([gui-cs/Terminal.Gui#4234](https://github.com/gui-cs/Terminal.Gui/pull/4234)) +### Enhanced Status Bar + +- Status bar now shows **EchoHub** branding at the start +- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating) +- Current channel shows its type: `#channel - public` or `#channel - private` + +### #general Channel Protection + +- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow) +- Users cannot `/leave` the #general channel (client-side guard) +- Connecting while already connected now prompts to disconnect first instead of silently leaking the previous connection + ## Fixes ### IRC Gateway diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 819c57d..2898fed 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -238,6 +238,12 @@ public sealed class AppOrchestrator : IDisposable 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); @@ -367,6 +373,16 @@ public sealed class AppOrchestrator : IDisposable private void HandleConnect() { + if (IsConnected) + { + var confirm = MessageBox.Query(_app, "Already Connected", + "You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel"); + + if (confirm != 0) return; + + HandleDisconnect(); + } + var result = ConnectDialog.Show(_app, _config.SavedServers); if (result is null) return; @@ -934,7 +950,7 @@ public sealed class AppOrchestrator : IDisposable InvokeUI(() => { if (channel.IsPublic) - _mainWindow.EnsureChannelInList(channel.Name); + _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic); _mainWindow.SetChannelTopic(channel.Name, channel.Topic); }); }; diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 280adc1..4536f16 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable 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 string _connectionStatus = "Disconnected"; private int _lastChatWidth; /// @@ -234,16 +236,17 @@ public sealed class MainWindow : Runnable _usersFrame.Add(_usersList); Add(_usersFrame); - // Status bar at the very bottom + // Status bar at the very bottom — custom drawing for colored connection state _statusLabel = new Label { - Text = "Disconnected", + Text = "", X = 0, Y = Pos.AnchorEnd(1), Width = Dim.Fill(), Height = 1 }; _statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); + _statusLabel.DrawingContent += OnStatusBarDrawContent; Add(_statusLabel); // Apply our custom color schemes to all views @@ -636,10 +639,12 @@ public sealed class MainWindow : Runnable { _channelNames.Clear(); _channelTopics.Clear(); + _channelPublic.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); _channelTopics[ch.Name] = ch.Topic; + _channelPublic[ch.Name] = ch.IsPublic; if (!_channelMessages.ContainsKey(ch.Name)) _channelMessages[ch.Name] = []; } @@ -649,8 +654,11 @@ public sealed class MainWindow : Runnable /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// - public void EnsureChannelInList(string channelName) + public void EnsureChannelInList(string channelName, bool? isPublic = null) { + if (isPublic.HasValue) + _channelPublic[channelName] = isPublic.Value; + if (_channelNames.Contains(channelName)) return; @@ -667,6 +675,7 @@ public sealed class MainWindow : Runnable { _channelNames.Remove(channelName); _channelTopics.Remove(channelName); + _channelPublic.Remove(channelName); RefreshChannelList(); } @@ -693,9 +702,76 @@ public sealed class MainWindow : Runnable /// public void UpdateStatusBar(string status) { - var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; - var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; - _statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; + _connectionStatus = status; + _statusLabel.SetNeedsDraw(); + } + + private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.Transparent); + private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent); + private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent); + private static readonly Attribute StatusBrandAttr = new(new Color(100, 160, 255), Color.Transparent); + + private void OnStatusBarDrawContent(object? sender, DrawEventArgs e) + { + var menuScheme = SchemeManager.GetScheme("Menu"); + var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal); + var width = _statusLabel.Viewport.Width; + if (width <= 0) return; + + // Resolve transparent background for colored segments + var bg = normalAttr.Background; + Attribute Resolve(Attribute a) => a.Background == Color.Transparent ? a with { Background = bg } : a; + + int col = 0; + + void Write(string text, Attribute attr) + { + _statusLabel.SetAttribute(Resolve(attr)); + foreach (var g in GraphemeHelper.GetGraphemes(text)) + { + var cols = Math.Max(g.GetColumns(), 1); + if (col + cols > width) return; + _statusLabel.Move(col, 0); + _statusLabel.AddStr(g); + col += cols; + } + } + + // EchoHub branding + Write(" EchoHub", Resolve(StatusBrandAttr)); + Write($" \u2502 v{AppVersion} \u2502 ", normalAttr); + + // Connection state with color + var statusAttr = _connectionStatus switch + { + "Connected" => StatusConnectedAttr, + "Disconnected" => StatusDisconnectedAttr, + _ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc. + }; + Write(_connectionStatus, Resolve(statusAttr)); + + // User + if (!string.IsNullOrEmpty(_currentUser)) + Write($" \u2502 User: {_currentUser}", normalAttr); + + // Channel + type + if (!string.IsNullOrEmpty(_currentChannel)) + { + _channelPublic.TryGetValue(_currentChannel, out var isPublic); + var typeSuffix = isPublic ? "public" : "private"; + Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr); + } + + // Fill remaining space + _statusLabel.SetAttribute(normalAttr); + while (col < width) + { + _statusLabel.Move(col, 0); + _statusLabel.AddStr(" "); + col++; + } + + e.Cancel = true; } /// @@ -730,6 +806,7 @@ public sealed class MainWindow : Runnable RefreshMessages(); UpdateTopicBar(); + _statusLabel.SetNeedsDraw(); // Update channel list selection var idx = _channelNames.IndexOf(channelName); @@ -761,6 +838,7 @@ public sealed class MainWindow : Runnable _channelMessages.Clear(); _channelUnread.Clear(); _channelTopics.Clear(); + _channelPublic.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; _channelListSource.Update([], [], string.Empty); diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index e9ace7f..a0c0725 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -52,6 +52,19 @@ public class ChannelsController : ControllerBase offset = Math.Max(0, offset); limit = Math.Clamp(limit, 1, 100); + // Ensure #general always exists + if (!await _db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) + { + _db.Channels.Add(new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }); + await _db.SaveChangesAsync(); + } + // Public channels + private channels the user has joined var query = _db.Channels.Where(c => c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index a75343b..a979e87 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -103,7 +103,26 @@ public class ChatService : IChatService var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) - return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); + { + // Auto-recreate #general if it was somehow removed + if (channelName == HubConstants.DefaultChannel) + { + channel = new Channel + { + Id = Guid.NewGuid(), + Name = HubConstants.DefaultChannel, + Topic = "General discussion", + CreatedByUserId = Guid.Empty, + }; + db.Channels.Add(channel); + await db.SaveChangesAsync(); + _logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel); + } + else + { + return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list."); + } + } // Persist membership so the channel shows in the user's channel list var hasMembership = await db.ChannelMemberships diff --git a/src/Terminal.Gui b/src/Terminal.Gui index 8d1557b..23d929e 160000 --- a/src/Terminal.Gui +++ b/src/Terminal.Gui @@ -1 +1 @@ -Subproject commit 8d1557b2fcbe26ac751f2b39842030c6c07c3f33 +Subproject commit 23d929e523f2466dc011a17265e79caf36a768b4