Files
Relay/RelayServer/Services/Chat/ConnectedClientService.cs
2026-06-06 23:38:50 -04:00

100 lines
4.0 KiB
C#

using System.Collections.Concurrent;
namespace RelayServer.Services.Chat;
/// <summary>
/// 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.
/// </summary>
public static class ConnectedClientService
{
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 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.
/// </summary>
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<string>(StringComparer.Ordinal));
lock (sessions)
sessions.Add(sessionId);
}
/// <summary>
/// Removes a session from both mappings. Called from OnClose. Idempotent — calling for
/// a session that's already gone is a no-op.
/// </summary>
public static void Unregister(string sessionId)
{
if (SessionToUsername.TryRemove(sessionId, out var username))
RemoveSessionFromUsername(sessionId, username);
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
{
if (UsernameToSessions.TryGetValue(username, out var sessions))
{
lock (sessions)
return sessions.ToList();
}
return Array.Empty<string>();
}
/// <summary>
/// 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.
/// </summary>
public static string? GetUsernameForSession(string sessionId)
{
return SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
}
/// <summary>
/// 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).
/// </summary>
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 _);
}
}
}