using System.Collections.Concurrent; namespace EchoHub.Server.Services; public class PresenceTracker { private readonly ConcurrentDictionary _connections = new(); private readonly ConcurrentDictionary> _userConnections = new(); private readonly ConcurrentDictionary> _userChannels = new(); private readonly object _lock = new(); public void UserConnected(string connectionId, Guid userId, string username) { _connections[connectionId] = (userId, username); // Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside. // It also makes the TryGetValue → add sequence atomic to prevent race conditions. lock (_lock) { if (!_userConnections.TryGetValue(username, out var connections)) { connections = new HashSet(); _userConnections[username] = connections; } connections.Add(connectionId); } } public string? UserDisconnected(string connectionId) { if (!_connections.TryRemove(connectionId, out var userInfo)) return null; var username = userInfo.username; lock (_lock) { if (_userConnections.TryGetValue(username, out var connections)) { connections.Remove(connectionId); if (connections.Count == 0) { _userConnections.TryRemove(username, out _); _userChannels.TryRemove(username, out _); } } } return username; } /// /// Returns true if this is a new join, false if the user was already in the channel. /// public bool JoinChannel(string username, string channelName) { lock (_lock) { if (!_userChannels.TryGetValue(username, out var channels)) { channels = new HashSet(); _userChannels[username] = channels; } return channels.Add(channelName); } } public void LeaveChannel(string username, string channelName) { lock (_lock) { if (_userChannels.TryGetValue(username, out var channels)) { channels.Remove(channelName); } } } public List GetOnlineUsersInChannel(string channelName) { var users = new List(); lock (_lock) { foreach (var (username, channels) in _userChannels) { if (channels.Contains(channelName)) { users.Add(username); } } } return users; } public List GetChannelsForUser(string username) { lock (_lock) { if (_userChannels.TryGetValue(username, out var channels)) { return channels.ToList(); } } return []; } /// /// Get all unique connection IDs for users who share any of the given channels. /// public List GetConnectionsInChannels(List channels) { lock (_lock) { var usernames = new HashSet(); foreach (var channel in channels) { foreach (var (username, userChannels) in _userChannels) { if (userChannels.Contains(channel)) usernames.Add(username); } } var connections = new List(); foreach (var username in usernames) { if (_userConnections.TryGetValue(username, out var conns)) connections.AddRange(conns); } return connections; } } public string? GetUsernameForConnection(string connectionId) { return _connections.TryGetValue(connectionId, out var info) ? info.username : null; } public bool IsOnline(string username) { return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; } public int GetOnlineUserCount() { return _userConnections.Count; } /// /// Forcibly remove a user from all tracking. Returns their connection IDs and channels /// so the caller can broadcast departures and force-disconnect connections. /// public (List ConnectionIds, List Channels) ForceRemoveUser(string username) { lock (_lock) { var channels = _userChannels.TryRemove(username, out var ch) ? ch.ToList() : []; var connectionIds = _userConnections.TryRemove(username, out var conns) ? conns.ToList() : []; foreach (var connId in connectionIds) _connections.TryRemove(connId, out _); return (connectionIds, channels); } } }