mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: enhance channel management and status bar functionality
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+1
-1
Submodule src/Terminal.Gui updated: 8d1557b2fc...23d929e523
Reference in New Issue
Block a user