feat: enhance profile editing with avatar support and UI adjustments

- Added AvatarPath to ProfileEditResult for user profile updates.
- Updated ProfileEditDialog to include avatar selection with a browse button.
- Increased dialog height to accommodate new avatar input fields.
- Implemented avatar file path handling in the profile edit dialog.

feat: introduce moderation features and user roles

- Added ServerRole enum to define user roles (Member, Mod, Admin, Owner).
- Extended User model to include role, mute, and ban status.
- Created ModerationController for user role assignment, kicking, banning, and muting.
- Implemented methods in IChatBroadcaster and SignalRBroadcaster for user moderation actions.
- Updated database schema with new columns for user roles and moderation states.

fix: ensure muted users cannot send messages

- Added mute status checks in ChatService to prevent message sending for muted users.
- Updated user presence and status handling to reflect role changes and moderation actions.

chore: update constants for ASCII art rendering

- Introduced AsciiArtHeightHalfBlock constant for improved ASCII art rendering.
This commit is contained in:
HueByte
2026-02-19 17:47:10 +01:00
parent 257ac34224
commit b4c3ebd254
28 changed files with 1457 additions and 73 deletions
+168 -28
View File
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions;
using EchoHub.Client.Themes;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
@@ -21,10 +22,21 @@ public sealed class MainWindow : Runnable
private readonly ListView _messageList;
private readonly TextView _inputField;
private readonly FrameView _chatFrame;
private readonly FrameView _inputFrame;
private readonly Label _statusLabel;
private readonly Label _topicLabel;
private MenuBar _menuBar;
// Online users panel
private readonly FrameView _usersFrame;
private readonly ListView _usersList;
private bool _usersPanelVisible = true;
private const int UsersPanelWidth = 22;
private static readonly Key F2Key = Key.F2;
private static readonly string AppVersion =
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
private static readonly Key EnterKey = Key.Enter;
private static readonly Key NewlineKey = Key.N.WithCtrl;
@@ -36,13 +48,15 @@ public sealed class MainWindow : Runnable
[
"/status", "/nick", "/color", "/theme", "/send",
"/avatar", "/profile", "/servers", "/join", "/leave",
"/topic", "/users", "/quit", "/help"
"/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/quit", "/help"
];
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = [];
private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
private int _lastChatWidth;
@@ -92,6 +106,11 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action? OnCreateChannelRequested;
/// <summary>
/// Fired when the user requests to delete the current channel.
/// </summary>
public event Action? OnDeleteChannelRequested;
public MainWindow(IApplication app)
{
_app = app;
@@ -107,7 +126,7 @@ public sealed class MainWindow : Runnable
Title = "Channels",
X = 0,
Y = 1, // below menu bar
Width = 25,
Width = 22,
Height = Dim.Fill(1) // leave room for status bar
};
@@ -118,7 +137,8 @@ public sealed class MainWindow : Runnable
Width = Dim.Fill(),
Height = Dim.Fill()
};
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_channelListSource = new ChannelListSource();
_channelList.Source = _channelListSource;
_channelList.ValueChanged += OnChannelListSelectionChanged;
channelsFrame.Add(_channelList);
Add(channelsFrame);
@@ -127,9 +147,9 @@ public sealed class MainWindow : Runnable
_topicLabel = new Label
{
Text = "",
X = 25,
X = 22,
Y = 1,
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = 1,
Visible = false
};
@@ -139,9 +159,9 @@ public sealed class MainWindow : Runnable
_chatFrame = new FrameView
{
Title = "Chat",
X = 25,
X = 22,
Y = 1, // below menu bar (shifts to 2 when topic is visible)
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = Dim.Fill(6) // leave room for input area and status bar
};
@@ -157,12 +177,12 @@ public sealed class MainWindow : Runnable
Add(_chatFrame);
// Bottom input area
var inputFrame = new FrameView
_inputFrame = new FrameView
{
Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)",
X = 25,
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
X = 22,
Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(),
Width = Dim.Fill(UsersPanelWidth),
Height = 5
};
@@ -175,8 +195,29 @@ public sealed class MainWindow : Runnable
WordWrap = true
};
_inputField.KeyDown += OnInputKeyDown;
inputFrame.Add(_inputField);
Add(inputFrame);
_inputFrame.Add(_inputField);
Add(_inputFrame);
// Right panel - online users
_usersFrame = new FrameView
{
Title = "Users",
X = Pos.AnchorEnd(UsersPanelWidth),
Y = 1,
Width = UsersPanelWidth,
Height = Dim.Fill(1)
};
_usersList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
_usersList.SetSource(new ObservableCollection<string>());
_usersFrame.Add(_usersList);
Add(_usersFrame);
// Status bar at the very bottom
_statusLabel = new Label
@@ -198,7 +239,7 @@ public sealed class MainWindow : Runnable
_messageList.ViewportChanged += (_, _) => OnChatViewportChanged();
_chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged();
// Window-level key handling for Ctrl+C (quit)
// Window-level key handling for Ctrl+C (quit), F2 (toggle users panel)
KeyDown += OnWindowKeyDown;
}
@@ -265,7 +306,11 @@ public sealed class MainWindow : Runnable
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty),
new Line(),
new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty)
}),
new MenuBarItem("_User", allUserItems)
]);
@@ -386,12 +431,16 @@ public sealed class MainWindow : Runnable
private void OnWindowKeyDown(object? sender, Key e)
{
// Ctrl+C quits from anywhere
if (e.KeyCode == CtrlCKey.KeyCode)
{
_app.RequestStop();
e.Handled = true;
}
else if (e.KeyCode == F2Key.KeyCode)
{
ToggleUsersPanel();
e.Handled = true;
}
}
/// <summary>
@@ -475,6 +524,32 @@ public sealed class MainWindow : Runnable
}
}
/// <summary>
/// Remove all lines associated with a specific message ID.
/// </summary>
public void RemoveMessage(string channelName, Guid messageId)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.RemoveAll(l => l.MessageId == messageId);
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary>
/// Clear all messages from a specific channel.
/// </summary>
public void ClearChannelMessages(string channelName)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
if (channelName == _currentChannel)
RefreshMessages();
}
}
/// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary>
@@ -515,9 +590,9 @@ public sealed class MainWindow : Runnable
/// </summary>
public void UpdateStatusBar(string status)
{
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
_statusLabel.Text = $" {status}{userPart}{channelPart}";
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
}
/// <summary>
@@ -585,10 +660,13 @@ public sealed class MainWindow : Runnable
_channelTopics.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_channelListSource.Update([], [], string.Empty);
_channelList.Source = _channelListSource;
_chatFrame.Title = "Chat";
_topicLabel.Visible = false;
_chatFrame.Y = 1;
_usersList.SetSource(new ObservableCollection<string>());
_usersFrame.Title = "Users";
RefreshMessages();
}
@@ -640,13 +718,8 @@ public sealed class MainWindow : Runnable
/// </summary>
private void RefreshChannelList()
{
var displayNames = _channelNames.Select(name =>
{
_channelUnread.TryGetValue(name, out var unread);
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
}).ToList();
_channelList.SetSource(new ObservableCollection<string>(displayNames));
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
_channelList.Source = _channelListSource;
// Restore selection to current channel
var idx = _channelNames.IndexOf(_currentChannel);
@@ -673,11 +746,63 @@ public sealed class MainWindow : Runnable
}
}
/// <summary>
/// Adjusts widths of chat, topic, and input frames based on users panel visibility.
/// </summary>
private void UpdateLayout()
{
var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0;
_chatFrame.Width = Dim.Fill(rightMargin);
_topicLabel.Width = Dim.Fill(rightMargin);
_inputFrame.Width = Dim.Fill(rightMargin);
_usersFrame.Visible = _usersPanelVisible;
SetNeedsDraw();
}
/// <summary>
/// Toggle the online users panel visibility (F2).
/// </summary>
public void ToggleUsersPanel()
{
_usersPanelVisible = !_usersPanelVisible;
UpdateLayout();
}
/// <summary>
/// Update the online users list display.
/// </summary>
public void UpdateOnlineUsers(List<UserPresenceDto> users)
{
var displayItems = users.Select(u =>
{
var statusIcon = u.Status switch
{
UserStatus.Online => "\u25cf", // ●
UserStatus.Away => "\u25cb", // ○
UserStatus.DoNotDisturb => "\u25d0", // ◐
UserStatus.Invisible => "\u25cc", // ◌
_ => " "
};
var name = u.DisplayName ?? u.Username;
var roleTag = u.Role switch
{
ServerRole.Owner => "\u2605", // ★
ServerRole.Admin => "\u2666", // ♦
ServerRole.Mod => "\u2740", // ❀
_ => ""
};
return $"{statusIcon} {roleTag}{name}";
}).ToList();
_usersList.SetSource(new ObservableCollection<string>(displayItems));
_usersFrame.Title = $"Users ({users.Count})";
}
/// <summary>
/// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored.
/// </summary>
private static List<ChatLine> FormatMessage(MessageDto message)
private List<ChatLine> FormatMessage(MessageDto message)
{
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var senderName = message.SenderUsername + ":";
@@ -724,6 +849,21 @@ public sealed class MainWindow : Runnable
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;
}