mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-06 07:36:01 +02:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user