feat: enhance channel management and status bar functionality

This commit is contained in:
HueByte
2026-02-21 16:02:49 +01:00
parent ab9e09a609
commit a65b41fb7f
6 changed files with 147 additions and 9 deletions
+12
View File
@@ -37,6 +37,18 @@
- Dialogs retain solid `DarkGray` background for readability - 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)) - 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 ## Fixes
### IRC Gateway ### IRC Gateway
+17 -1
View File
@@ -238,6 +238,12 @@ public sealed class AppOrchestrator : IDisposable
var channel = _mainWindow.CurrentChannel; var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(channel)) return;
if (channel == HubConstants.DefaultChannel)
{
InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
return;
}
try try
{ {
await _connection!.LeaveChannelAsync(channel); await _connection!.LeaveChannelAsync(channel);
@@ -367,6 +373,16 @@ public sealed class AppOrchestrator : IDisposable
private void HandleConnect() 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); var result = ConnectDialog.Show(_app, _config.SavedServers);
if (result is null) return; if (result is null) return;
@@ -934,7 +950,7 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => InvokeUI(() =>
{ {
if (channel.IsPublic) if (channel.IsPublic)
_mainWindow.EnsureChannelInList(channel.Name); _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
_mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
}); });
}; };
+84 -6
View File
@@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable
private readonly Dictionary<string, List<ChatLine>> _channelMessages = []; private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = []; private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = []; private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly ChannelListSource _channelListSource; private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty; private string _currentChannel = string.Empty;
private string _currentUser = string.Empty; private string _currentUser = string.Empty;
private string _connectionStatus = "Disconnected";
private int _lastChatWidth; private int _lastChatWidth;
/// <summary> /// <summary>
@@ -234,16 +236,17 @@ public sealed class MainWindow : Runnable
_usersFrame.Add(_usersList); _usersFrame.Add(_usersList);
Add(_usersFrame); Add(_usersFrame);
// Status bar at the very bottom // Status bar at the very bottom — custom drawing for colored connection state
_statusLabel = new Label _statusLabel = new Label
{ {
Text = "Disconnected", Text = "",
X = 0, X = 0,
Y = Pos.AnchorEnd(1), Y = Pos.AnchorEnd(1),
Width = Dim.Fill(), Width = Dim.Fill(),
Height = 1 Height = 1
}; };
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); _statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
_statusLabel.DrawingContent += OnStatusBarDrawContent;
Add(_statusLabel); Add(_statusLabel);
// Apply our custom color schemes to all views // Apply our custom color schemes to all views
@@ -636,10 +639,12 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear();
foreach (var ch in channels) foreach (var ch in channels)
{ {
_channelNames.Add(ch.Name); _channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic; _channelTopics[ch.Name] = ch.Topic;
_channelPublic[ch.Name] = ch.IsPublic;
if (!_channelMessages.ContainsKey(ch.Name)) if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = []; _channelMessages[ch.Name] = [];
} }
@@ -649,8 +654,11 @@ public sealed class MainWindow : Runnable
/// <summary> /// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary> /// </summary>
public void EnsureChannelInList(string channelName) public void EnsureChannelInList(string channelName, bool? isPublic = null)
{ {
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
if (_channelNames.Contains(channelName)) if (_channelNames.Contains(channelName))
return; return;
@@ -667,6 +675,7 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Remove(channelName); _channelNames.Remove(channelName);
_channelTopics.Remove(channelName); _channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
RefreshChannelList(); RefreshChannelList();
} }
@@ -693,9 +702,76 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void UpdateStatusBar(string status) public void UpdateStatusBar(string status)
{ {
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; _connectionStatus = status;
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; _statusLabel.SetNeedsDraw();
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; }
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;
} }
/// <summary> /// <summary>
@@ -730,6 +806,7 @@ public sealed class MainWindow : Runnable
RefreshMessages(); RefreshMessages();
UpdateTopicBar(); UpdateTopicBar();
_statusLabel.SetNeedsDraw();
// Update channel list selection // Update channel list selection
var idx = _channelNames.IndexOf(channelName); var idx = _channelNames.IndexOf(channelName);
@@ -761,6 +838,7 @@ public sealed class MainWindow : Runnable
_channelMessages.Clear(); _channelMessages.Clear();
_channelUnread.Clear(); _channelUnread.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear();
_currentChannel = string.Empty; _currentChannel = string.Empty;
_currentUser = string.Empty; _currentUser = string.Empty;
_channelListSource.Update([], [], string.Empty); _channelListSource.Update([], [], string.Empty);
@@ -52,6 +52,19 @@ public class ChannelsController : ControllerBase
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); 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 // Public channels + private channels the user has joined
var query = _db.Channels.Where(c => var query = _db.Channels.Where(c =>
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
+20 -1
View File
@@ -103,7 +103,26 @@ public class ChatService : IChatService
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
return ([], $"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 // Persist membership so the channel shows in the user's channel list
var hasMembership = await db.ChannelMemberships var hasMembership = await db.ChannelMemberships