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:
@@ -20,6 +20,8 @@ public partial class ChatLine
|
||||
{
|
||||
public List<ChatSegment> Segments { get; }
|
||||
public int TextLength { get; }
|
||||
public Guid? MessageId { get; set; }
|
||||
public bool IsMention { get; set; }
|
||||
|
||||
public ChatLine(string plainText)
|
||||
{
|
||||
@@ -89,14 +91,25 @@ public partial class ChatLine
|
||||
|
||||
/// <summary>
|
||||
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
|
||||
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
|
||||
/// Supports foreground (\x1b[38;2;R;G;Bm), background (\x1b[48;2;R;G;Bm), and reset (\x1b[0m).
|
||||
/// </summary>
|
||||
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
|
||||
{
|
||||
var segments = new List<ChatSegment>();
|
||||
var regex = AnsiColorRegex();
|
||||
int lastIndex = 0;
|
||||
Attribute? currentColor = defaultAttr;
|
||||
Color? currentFg = null;
|
||||
Color? currentBg = null;
|
||||
var defaultFg = defaultAttr?.Foreground;
|
||||
var defaultBg = defaultAttr?.Background ?? Color.Black;
|
||||
|
||||
Attribute? BuildAttr()
|
||||
{
|
||||
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||
var bg = currentBg ?? defaultBg;
|
||||
return new Attribute(fg, bg);
|
||||
}
|
||||
|
||||
foreach (Match match in regex.Matches(ansiText))
|
||||
{
|
||||
@@ -105,22 +118,26 @@ public partial class ChatLine
|
||||
{
|
||||
var text = ansiText[lastIndex..match.Index];
|
||||
if (text.Length > 0)
|
||||
segments.Add(new ChatSegment(text, currentColor));
|
||||
segments.Add(new ChatSegment(text, BuildAttr()));
|
||||
}
|
||||
|
||||
// Parse the escape sequence
|
||||
if (match.Groups[1].Value == "0")
|
||||
{
|
||||
// Reset
|
||||
currentColor = defaultAttr;
|
||||
currentFg = null;
|
||||
currentBg = null;
|
||||
}
|
||||
else if (match.Groups[2].Success)
|
||||
{
|
||||
// 38;2;R;G;B — 24-bit foreground color
|
||||
var r = int.Parse(match.Groups[3].Value);
|
||||
var g = int.Parse(match.Groups[4].Value);
|
||||
var b = int.Parse(match.Groups[5].Value);
|
||||
currentColor = new Attribute(new Color(r, g, b), Color.Black);
|
||||
|
||||
if (match.Groups[2].Value == "38;2")
|
||||
currentFg = new Color(r, g, b);
|
||||
else // 48;2
|
||||
currentBg = new Color(r, g, b);
|
||||
}
|
||||
|
||||
lastIndex = match.Index + match.Length;
|
||||
@@ -131,14 +148,14 @@ public partial class ChatLine
|
||||
{
|
||||
var text = ansiText[lastIndex..];
|
||||
if (text.Length > 0)
|
||||
segments.Add(new ChatSegment(text, currentColor));
|
||||
segments.Add(new ChatSegment(text, BuildAttr()));
|
||||
}
|
||||
|
||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||
}
|
||||
|
||||
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
|
||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||
// Matches: \x1b[0m (reset), \x1b[38;2;R;G;Bm (fg), or \x1b[48;2;R;G;Bm (bg)
|
||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||
private static partial Regex AnsiColorRegex();
|
||||
}
|
||||
|
||||
@@ -198,6 +215,7 @@ public class ChatListSource : IListDataSource
|
||||
|
||||
var chatLine = _lines[item];
|
||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||
|
||||
int charPos = 0;
|
||||
int drawnChars = 0;
|
||||
@@ -205,6 +223,9 @@ public class ChatListSource : IListDataSource
|
||||
foreach (var segment in chatLine.Segments)
|
||||
{
|
||||
var attr = segment.Color ?? normalAttr;
|
||||
// Override background for mention-highlighted lines
|
||||
if (mentionBg.HasValue)
|
||||
attr = new Attribute(attr.Foreground, mentionBg.Value);
|
||||
listView.SetAttribute(attr);
|
||||
|
||||
foreach (var ch in segment.Text)
|
||||
@@ -218,8 +239,9 @@ public class ChatListSource : IListDataSource
|
||||
}
|
||||
}
|
||||
|
||||
// Fill remaining width with spaces using default colors
|
||||
listView.SetAttribute(normalAttr);
|
||||
// Fill remaining width with spaces
|
||||
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||
listView.SetAttribute(fillAttr);
|
||||
while (drawnChars < width)
|
||||
{
|
||||
listView.AddRune(new Rune(' '));
|
||||
@@ -242,6 +264,110 @@ public class ChatListSource : IListDataSource
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom list data source for colored channel list rendering.
|
||||
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||
/// </summary>
|
||||
public class ChannelListSource : IListDataSource
|
||||
{
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||
private string _activeChannel = string.Empty;
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
public int Count => _channelNames.Count;
|
||||
public int MaxItemLength { get; private set; }
|
||||
public bool SuspendCollectionChangedEvent { get; set; }
|
||||
|
||||
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
|
||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
|
||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
|
||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
|
||||
|
||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelNames.AddRange(channels);
|
||||
_unreadCounts.Clear();
|
||||
foreach (var kv in unread)
|
||||
_unreadCounts[kv.Key] = kv.Value;
|
||||
_activeChannel = activeChannel;
|
||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
public bool IsMarked(int item) => false;
|
||||
public void SetMark(int item, bool value) { }
|
||||
public IList ToList() => _channelNames.Select(n => $"#{n}").ToList();
|
||||
|
||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||
{
|
||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||
|
||||
var name = _channelNames[item];
|
||||
var isActive = name == _activeChannel;
|
||||
_unreadCounts.TryGetValue(name, out var unread);
|
||||
var hasUnread = unread > 0;
|
||||
|
||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||
var prefix = isActive ? "> " : " ";
|
||||
var channelText = $"#{name}";
|
||||
var badge = hasUnread ? $" ({unread})" : "";
|
||||
|
||||
int drawnChars = 0;
|
||||
|
||||
// Use focus attr if this row is selected
|
||||
if (selected)
|
||||
{
|
||||
listView.SetAttribute(focusAttr);
|
||||
foreach (var ch in (prefix + channelText + badge))
|
||||
{
|
||||
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prefix
|
||||
var prefixAttr = isActive ? ActiveAttr : NormalAttr;
|
||||
listView.SetAttribute(prefixAttr);
|
||||
foreach (var ch in prefix)
|
||||
{
|
||||
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||
}
|
||||
|
||||
// Channel name
|
||||
var nameAttr = isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr;
|
||||
listView.SetAttribute(nameAttr);
|
||||
foreach (var ch in channelText)
|
||||
{
|
||||
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||
}
|
||||
|
||||
// Unread badge
|
||||
if (hasUnread)
|
||||
{
|
||||
listView.SetAttribute(BadgeAttr);
|
||||
foreach (var ch in badge)
|
||||
{
|
||||
if (drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill rest
|
||||
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
|
||||
listView.SetAttribute(fillAttr);
|
||||
while (drawnChars < width)
|
||||
{
|
||||
listView.AddRune(new Rune(' '));
|
||||
drawnChars++;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||
/// </summary>
|
||||
@@ -249,6 +375,7 @@ public static class ChatColors
|
||||
{
|
||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
||||
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace EchoHub.Client.UI;
|
||||
/// <summary>
|
||||
/// Result returned from the profile edit dialog.
|
||||
/// </summary>
|
||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath);
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||
@@ -23,7 +23,7 @@ public sealed class ProfileEditDialog
|
||||
{
|
||||
ProfileEditResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 22 };
|
||||
|
||||
// Display Name
|
||||
var nameLabel = new Label
|
||||
@@ -102,20 +102,66 @@ public sealed class ProfileEditDialog
|
||||
UpdateColorPreview(colorPreview, colorField.Text);
|
||||
};
|
||||
|
||||
// Avatar
|
||||
var avatarLabel = new Label
|
||||
{
|
||||
Text = "Avatar:",
|
||||
X = 1,
|
||||
Y = 10
|
||||
};
|
||||
var avatarField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 17,
|
||||
Y = 10,
|
||||
Width = Dim.Fill(12)
|
||||
};
|
||||
var browseButton = new Button
|
||||
{
|
||||
Text = "Browse",
|
||||
X = Pos.AnchorEnd(10),
|
||||
Y = 10
|
||||
};
|
||||
var avatarHintLabel = new Label
|
||||
{
|
||||
Text = "(file path or URL)",
|
||||
X = 17,
|
||||
Y = 11
|
||||
};
|
||||
avatarHintLabel.SetScheme(new Scheme
|
||||
{
|
||||
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||
});
|
||||
|
||||
browseButton.Accepting += (s, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
var openDialog = new OpenDialog
|
||||
{
|
||||
Title = "Select Avatar Image",
|
||||
OpenMode = OpenMode.File,
|
||||
};
|
||||
app.Run(openDialog);
|
||||
if (openDialog.FilePaths.Count > 0)
|
||||
{
|
||||
avatarField.Text = openDialog.FilePaths[0];
|
||||
}
|
||||
};
|
||||
|
||||
// Buttons
|
||||
var saveButton = new Button
|
||||
{
|
||||
Text = "Save",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 10,
|
||||
Y = 10
|
||||
Y = 14
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 5,
|
||||
Y = 10
|
||||
Y = 14
|
||||
};
|
||||
|
||||
saveButton.Accepting += (s, e) =>
|
||||
@@ -123,8 +169,9 @@ public sealed class ProfileEditDialog
|
||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
|
||||
|
||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
||||
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -137,7 +184,9 @@ public sealed class ProfileEditDialog
|
||||
};
|
||||
|
||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
||||
colorHintLabel, previewLabel, colorPreview,
|
||||
avatarLabel, avatarField, browseButton, avatarHintLabel,
|
||||
saveButton, cancelButton);
|
||||
|
||||
nameField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
Reference in New Issue
Block a user