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)