using System.Collections.Concurrent; namespace RelayServer.Services.Chat; /// /// Two-way in-memory mapping between WebSocket session IDs and usernames. /// /// Why both directions: when a chat message arrives, we need to look up "which sessions does /// this server member have open right now?" (username → sessions) so we can deliver to each /// of their devices. When a connection closes, we need to know "which user owned this session?" /// (session → username) to clean up correctly. /// /// Multi-device support: one username can have multiple sessions (phone + desktop + web all /// connected simultaneously). UsernameToSessions stores a HashSet per username; each lock /// is scoped to that specific HashSet so different users never block each other. /// /// Username comparisons are case-insensitive (OrdinalIgnoreCase on the outer dictionary) /// because the DB stores usernames lowercase but clients may register with mixed case. /// public static class ConnectedClientService { private static readonly ConcurrentDictionary SessionToUsername = new(); private static readonly ConcurrentDictionary> UsernameToSessions = new(StringComparer.OrdinalIgnoreCase); /// /// Associates a session ID with a username. Called from HandleRegisterKey. If the same /// session re-registers under a different username (rare — basically only if the client /// reauthenticates), the old mapping is cleaned up first to avoid double-bookkeeping. /// public static void Register(string sessionId, string username) { if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) && !string.Equals(oldUsername, username, StringComparison.OrdinalIgnoreCase)) { RemoveSessionFromUsername(sessionId, oldUsername); } SessionToUsername[sessionId] = username; var sessions = UsernameToSessions.GetOrAdd( username, _ => new HashSet(StringComparer.Ordinal)); lock (sessions) sessions.Add(sessionId); } /// /// Removes a session from both mappings. Called from OnClose. Idempotent — calling for /// a session that's already gone is a no-op. /// public static void Unregister(string sessionId) { if (SessionToUsername.TryRemove(sessionId, out var username)) RemoveSessionFromUsername(sessionId, username); } /// /// Returns every active session ID for a given username (case-insensitive lookup). /// Empty collection if the user is offline. Snapshot-safe: the returned list is a copy, /// not a live view of the underlying HashSet. /// public static IReadOnlyCollection GetSessionsForUser(string username) { if (UsernameToSessions.TryGetValue(username, out var sessions)) { lock (sessions) return sessions.ToList(); } return Array.Empty(); } /// /// Reverse lookup: which user owns this session? Returns the mixed-case username the /// client registered with (preserves casing for display). Null if the session is unknown. /// public static string? GetUsernameForSession(string sessionId) { return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null; } /// /// Internal cleanup: pulls a session out of the username→sessions HashSet, and removes /// the username entry entirely if no sessions remain (keeps the dictionary lean). /// private static void RemoveSessionFromUsername(string sessionId, string username) { if (!UsernameToSessions.TryGetValue(username, out var sessions)) return; lock (sessions) { sessions.Remove(sessionId); if (sessions.Count == 0) UsernameToSessions.TryRemove(username, out _); } } }