feat: enhance user presence and channel interaction features

- Fix userlist not refreshing after creating a new channel.
- Implement unmute timer with a background job to automatically unmute users.
- Improve userlist display by filtering invisible users and ensuring proper transitions between statuses.
- Add clickable usernames, @mentions, and #channels for easier navigation.
- Embed theme colors from source sites for a more cohesive UI.
- Introduce a stateful userlist that updates incrementally via SignalR events.
- Restrict auto-opening of files to safe types only, enhancing security.
- Refactor user management into a dedicated service to reduce code duplication.
- Add a MuteExpirationService to handle timed mutes.
- Update documentation with Mermaid diagrams for major flows.
This commit is contained in:
HueByte
2026-02-24 20:56:40 +01:00
parent 8647b05c12
commit 0c16f44db6
21 changed files with 451 additions and 49 deletions
+140 -9
View File
@@ -28,6 +28,8 @@ public sealed class AppOrchestrator : IDisposable
private readonly AudioPlaybackService _audioPlayback = new();
private readonly UpdateChecker _updateService;
private readonly ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new();
private ClientConfig _config;
private readonly UserSession _session = new();
@@ -87,6 +89,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
}
// ── Command Handler Wiring ─────────────────────────────────────────────
@@ -381,17 +385,48 @@ public sealed class AppOrchestrator : IDisposable
}
};
_conn.UserJoined += (channelName, username) =>
_conn.UserJoined += (channelName, username, presence) =>
{
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} joined the channel"));
if (channelName == _mainWindow.CurrentChannel)
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (presence is not null && _channelUsers.TryGetValue(channelName, out var users))
{
if (!users.Any(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase)))
users.Add(presence);
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
FetchAndUpdateOnlineUsers();
};
_conn.UserLeft += (channelName, username) =>
{
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} left the channel"));
if (channelName == _mainWindow.CurrentChannel)
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (_channelUsers.TryGetValue(channelName, out var users))
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
else if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
FetchAndUpdateOnlineUsers();
};
@@ -407,18 +442,75 @@ public sealed class AppOrchestrator : IDisposable
foreach (var channelName in _mainWindow.GetChannelNames())
_messageManager.AddStatusMessage(channelName, displayName, statusText);
});
FetchAndUpdateOnlineUsers();
// Update presence in all cached channel lists
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
foreach (var (channel, users) in _channelUsers)
{
var idx = users.FindIndex(u => u.Username.Equals(presence.Username, StringComparison.OrdinalIgnoreCase));
if (idx >= 0)
{
if (presence.Status == UserStatus.Invisible)
users.RemoveAt(idx);
else
users[idx] = presence;
}
else if (presence.Status != UserStatus.Invisible)
{
// User came back from invisible — re-add them
users.Add(presence);
}
}
var currentChannel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
snapshot = [.. currentUsers];
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
};
_conn.UserKicked += (channelName, username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
InvokeUI(() => _messageManager.AddSystemMessage(channelName, $"{username} was kicked{reasonText}"));
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
if (_channelUsers.TryGetValue(channelName, out var users))
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
if (channelName.Equals(_mainWindow.CurrentChannel, StringComparison.OrdinalIgnoreCase))
snapshot = [.. users];
}
}
if (snapshot is not null)
InvokeUI(() => _mainWindow.UpdateOnlineUsers(snapshot));
};
_conn.UserBanned += (username, reason) =>
{
var reasonText = reason is not null ? $" ({reason})" : "";
List<UserPresenceDto>? snapshot = null;
lock (_channelUsersLock)
{
// Remove banned user from all cached channel lists
foreach (var (channel, users) in _channelUsers)
{
users.RemoveAll(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase));
}
var currentChannel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(currentChannel) && _channelUsers.TryGetValue(currentChannel, out var currentUsers))
snapshot = [.. currentUsers];
}
InvokeUI(() =>
{
if (!username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase))
@@ -426,6 +518,9 @@ public sealed class AppOrchestrator : IDisposable
var channel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(channel))
_messageManager.AddSystemMessage(channel, $"{username} was banned{reasonText}");
if (snapshot is not null)
_mainWindow.UpdateOnlineUsers(snapshot);
}
});
};
@@ -469,6 +564,7 @@ public sealed class AppOrchestrator : IDisposable
_conn.Reconnected += () =>
{
lock (_channelUsersLock) _channelUsers.Clear();
RunAsync(
async () => await _conn.RejoinChannelsAsync(),
"Failed to rejoin channels after reconnect");
@@ -535,6 +631,7 @@ public sealed class AppOrchestrator : IDisposable
private void HandleDisconnect()
{
Log.Information("Disconnecting from server");
lock (_channelUsersLock) _channelUsers.Clear();
RunAsync(async () =>
{
@@ -623,6 +720,19 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to join channel");
}
private void HandleChannelJoinFromMessage(string channelName)
{
if (!_conn.IsConnected) return;
InvokeUI(() =>
{
_mainWindow.EnsureChannelInList(channelName);
_mainWindow.SwitchToChannel(channelName);
});
HandleChannelSelected(channelName);
}
private void HandleProfileRequested()
{
HandleViewProfile(null);
@@ -826,6 +936,8 @@ public sealed class AppOrchestrator : IDisposable
if (history.Count > 0)
_messageManager.LoadHistory(channel.Name, history);
});
FetchAndUpdateOnlineUsers();
}, "Failed to create channel");
}
@@ -881,6 +993,16 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to play audio");
}
/// <summary>
/// File extensions considered safe to open with the system default application.
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
/// </summary>
private static readonly HashSet<string> SafeOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".webm", ".mkv", ".avi", ".mov", // video
".pdf", ".txt", ".csv", ".json", ".xml", // documents
};
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
{
if (!_conn.IsAuthenticated) return;
@@ -890,14 +1012,22 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
try
var ext = Path.GetExtension(fileName);
if (SafeOpenExtensions.Contains(ext))
{
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
try
{
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}
catch (Exception ex)
else
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}, "Failed to download file");
@@ -946,6 +1076,7 @@ public sealed class AppOrchestrator : IDisposable
try
{
var users = await _conn.GetOnlineUsersAsync(channel);
lock (_channelUsersLock) _channelUsers[channel] = users;
InvokeUI(() => _mainWindow.UpdateOnlineUsers(users));
}
catch (Exception ex)
@@ -35,7 +35,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
// ── Events (forwarded from SignalR) ───────────────────────────────────
public event Action<MessageDto>? MessageReceived;
public event Action<string, string>? UserJoined;
public event Action<string, string, UserPresenceDto?>? UserJoined;
public event Action<string, string>? UserLeft;
public event Action<UserPresenceDto>? UserStatusChanged;
public event Action<string, string, string?>? UserKicked;
@@ -234,7 +234,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
private void WireConnectionEvents(EchoHubConnection connection)
{
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
connection.OnUserJoined += (ch, user, presence) => UserJoined?.Invoke(ch, user, presence);
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
@@ -11,7 +11,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
private readonly ClientEncryptionService _encryption;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string>? OnUserJoined;
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
public event Action<string, string>? OnUserLeft;
public event Action<ChannelDto>? OnChannelUpdated;
public event Action<UserPresenceDto>? OnUserStatusChanged;
@@ -70,9 +70,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnMessageReceived?.Invoke(decrypted);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
{
OnUserJoined?.Invoke(channelName, username);
OnUserJoined?.Invoke(channelName, username, presence);
});
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
+34 -5
View File
@@ -13,6 +13,7 @@ public static partial class ChatColors
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
public static readonly Attribute ChannelRefAttr = new(new Color(100, 200, 255), Color.None);
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
@@ -21,8 +22,8 @@ public static partial class ChatColors
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// Split text around @mentions and #channels, giving each the appropriate accent color.
/// Non-special text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
@@ -38,12 +39,40 @@ public static partial class ChatColors
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
// Second pass: highlight #channels in non-mention segments
var mentionSegments = segments;
segments = [];
foreach (var seg in mentionSegments)
{
if (seg.Color != null && seg.Color != defaultColor)
{
// Already colored (mention) — keep as-is
segments.Add(seg);
continue;
}
int segLast = 0;
foreach (Match match in ChannelRefRegex().Matches(seg.Text))
{
if (match.Index > segLast)
segments.Add(new ChatSegment(seg.Text[segLast..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, ChannelRefAttr));
segLast = match.Index + match.Length;
}
if (segLast < seg.Text.Length)
segments.Add(new ChatSegment(seg.Text[segLast..], defaultColor));
}
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
// @mention — not preceded by a word char (avoids emails)
[GeneratedRegex(@"(?<!\w)@[\w-]+")]
private static partial Regex MentionRegex();
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
[GeneratedRegex(@"(?<!\w)#(?=.*[a-zA-Z])[\w-]+")]
private static partial Regex ChannelRefRegex();
}
+3 -1
View File
@@ -19,6 +19,7 @@ public partial class ChatLine
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
public MessageType? Type { get; set; }
public string? SenderUsername { get; set; }
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
public int ContinuationIndent { get; set; }
@@ -118,13 +119,14 @@ public partial class ChatLine
if (results.Count == 0)
return [this];
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
// Propagate metadata to all wrapped lines so they remain clickable
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
wrapped.AttachmentFileName = AttachmentFileName;
wrapped.Type = Type;
wrapped.MessageId = MessageId;
wrapped.SenderUsername = SenderUsername;
}
return results;
@@ -272,7 +272,10 @@ public sealed class ChatMessageManager
}
foreach (var line in lines)
{
line.MessageId = message.Id;
line.SenderUsername = message.SenderUsername;
}
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
@@ -329,18 +332,20 @@ public sealed class ChatMessageManager
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr;
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(border, borderAttr),
new ChatSegment(text, color)
]));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
AddTextLine(embed.SiteName, borderAttr);
if (!string.IsNullOrWhiteSpace(embed.Title))
{
@@ -12,14 +12,14 @@ namespace EchoHub.Client.UI.ListSources;
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
private readonly List<(string Text, Attribute? NameColor, string Username)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
public void Update(List<(string Text, Attribute? NameColor, string Username)> users)
{
_users.Clear();
_users.AddRange(users);
@@ -28,15 +28,18 @@ public class UserListSource : IListDataSource
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public string? GetUsername(int index) =>
index >= 0 && index < _users.Count ? _users[index].Username : null;
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public IList ToList() => _users.Select(u => (object)u.Text).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 (text, nameColor) = _users[item];
var (text, nameColor, _) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
+79 -11
View File
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using EchoHub.Client.Services;
using EchoHub.Client.Themes;
using EchoHub.Client.UI.Chat;
@@ -19,7 +20,7 @@ namespace EchoHub.Client.UI;
/// <summary>
/// Main Terminal.Gui window for the EchoHub chat client.
/// </summary>
public sealed class MainWindow : Runnable
public sealed partial class MainWindow : Runnable
{
private readonly IApplication _app;
private readonly ListView _channelList;
@@ -140,6 +141,16 @@ public sealed class MainWindow : Runnable
/// </summary>
public event Action<string, string>? OnFileDownloadRequested;
/// <summary>
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
/// </summary>
public event Action<string>? OnUserProfileRequested;
/// <summary>
/// Fired when the user activates a #channel reference in a message. Parameter is the channel name.
/// </summary>
public event Action<string>? OnChannelJoinRequested;
public MainWindow(IApplication app, ChatMessageManager messageManager)
{
_app = app;
@@ -250,6 +261,7 @@ public sealed class MainWindow : Runnable
};
_usersListSource = new UserListSource();
_usersList.Source = _usersListSource;
_usersList.Accepting += OnUsersListAccepting;
_usersFrame.Add(_usersList);
Add(_usersFrame);
@@ -410,17 +422,66 @@ public sealed class MainWindow : Runnable
return;
var line = source.GetLine(index.Value);
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
return;
if (line is null) return;
if (line.Type == MessageType.Audio)
// Audio/file attachments take priority
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
{
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
if (line.Type == MessageType.Audio)
{
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
if (line.Type == MessageType.File)
{
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
}
var lineText = line.ToString();
// Check for @mention — open mentioned user's profile
// Negative lookbehind prevents matching emails (user@domain)
var mentionMatch = ClickMentionRegex().Match(lineText);
if (mentionMatch.Success)
{
OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value);
e.Handled = true;
return;
}
// Check for #channel — join/switch to that channel
// Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123)
var channelMatch = ClickChannelRegex().Match(lineText);
if (channelMatch.Success)
{
OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value);
e.Handled = true;
return;
}
// Default: open sender's profile
if (line.SenderUsername is not null)
{
OnUserProfileRequested?.Invoke(line.SenderUsername);
e.Handled = true;
}
else if (line.Type == MessageType.File)
}
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
{
var index = _usersList.SelectedItem;
if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count)
return;
var username = _usersListSource.GetUsername(index.Value);
if (username is not null)
{
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
OnUserProfileRequested?.Invoke(username);
e.Handled = true;
}
}
@@ -862,14 +923,14 @@ public sealed class MainWindow : Runnable
var name = u.DisplayName ?? u.Username;
var roleTag = u.Role switch
{
ServerRole.Owner => "\u2605", // ★
ServerRole.Admin => "\u2666", // ♦
ServerRole.Mod => "\u2740", // ❀
ServerRole.Owner => "\u2605 ", // ★
ServerRole.Admin => "\u2666 ", // ♦
ServerRole.Mod => "\u2740 ", // ❀
_ => ""
};
var text = $"{statusIcon} {roleTag}{name}";
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
return (text, nameColor, u.Username);
}).ToList();
_usersListSource.Update(displayItems);
@@ -877,4 +938,11 @@ public sealed class MainWindow : Runnable
_usersFrame.Title = $"Users ({users.Count})";
}
// @mention — not preceded by a word char (avoids emails)
[GeneratedRegex(@"(?<!\w)@([\w-]+)")]
private static partial Regex ClickMentionRegex();
// #channel — not preceded by a word char, must contain at least one letter (avoids hex colors / issue numbers)
[GeneratedRegex(@"(?<!\w)#((?=.*[a-zA-Z])[\w-]+)")]
private static partial Regex ClickChannelRegex();
}