mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user