using ChessCubing.Server.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; namespace ChessCubing.Server.Social; [Authorize] public sealed class SocialHub( ConnectedUserTracker tracker, MySqlSocialStore socialStore, MySqlUserProfileStore profileStore, PlayInviteCoordinator playInviteCoordinator) : Hub { private readonly ConnectedUserTracker _tracker = tracker; private readonly MySqlSocialStore _socialStore = socialStore; private readonly MySqlUserProfileStore _profileStore = profileStore; private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator; public override async Task OnConnectedAsync() { var siteUser = RequireCurrentUser(); var becameOnline = _tracker.TrackConnection(Context.ConnectionId, siteUser.Subject); await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted); if (becameOnline) { var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(siteUser.Subject, Context.ConnectionAborted); await NotifyPresenceChangedAsync(relevantSubjects, siteUser.Subject, isOnline: true); } await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { var (subject, becameOffline) = _tracker.RemoveConnection(Context.ConnectionId); if (becameOffline && !string.IsNullOrWhiteSpace(subject)) { var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, CancellationToken.None); await NotifyPresenceChangedAsync(relevantSubjects, subject, isOnline: false); } await base.OnDisconnectedAsync(exception); } public async Task RequestPresenceSnapshot() { var siteUser = RequireCurrentUser(); await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted); } public async Task SendPlayInvite(string recipientSubject, string recipientColor) { var sender = RequireCurrentUser(); var normalizedRecipientSubject = recipientSubject.Trim(); if (!await _socialStore.AreFriendsAsync(sender.Subject, normalizedRecipientSubject, Context.ConnectionAborted)) { throw new HubException("Seuls tes amis peuvent recevoir une invitation de partie."); } if (!_tracker.IsOnline(normalizedRecipientSubject)) { throw new HubException("Cet ami n'est plus connecte pour le moment."); } var senderProfile = await _profileStore.GetOrCreateAsync(sender, Context.ConnectionAborted); var recipientProfile = await _profileStore.FindBySubjectAsync(normalizedRecipientSubject, Context.ConnectionAborted); if (recipientProfile is null) { throw new HubException("Le profil de cet ami est introuvable."); } try { var message = _playInviteCoordinator.CreateInvite( new PlayInviteParticipant(sender.Subject, sender.Username, senderProfile.DisplayName), new PlayInviteParticipant(recipientProfile.Subject, recipientProfile.Username, recipientProfile.DisplayName), recipientColor); await Clients.Users([sender.Subject, recipientProfile.Subject]) .SendAsync("PlayInviteUpdated", message, Context.ConnectionAborted); return message; } catch (SocialValidationException exception) { throw new HubException(exception.Message); } } public async Task RespondToPlayInvite(string inviteId, bool accept) { var recipient = RequireCurrentUser(); try { if (accept) { var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject); await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject]) .SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted); return; } var declined = _playInviteCoordinator.DeclineInvite(inviteId, recipient.Subject); await Clients.Users([declined.SenderSubject, declined.RecipientSubject]) .SendAsync("PlayInviteClosed", declined.ClosedMessage, Context.ConnectionAborted); } catch (SocialValidationException exception) { throw new HubException(exception.Message); } } public async Task CancelPlayInvite(string inviteId) { var sender = RequireCurrentUser(); try { var cancelled = _playInviteCoordinator.CancelInvite(inviteId, sender.Subject); await Clients.Users([cancelled.SenderSubject, cancelled.RecipientSubject]) .SendAsync("PlayInviteClosed", cancelled.ClosedMessage, Context.ConnectionAborted); } catch (SocialValidationException exception) { throw new HubException(exception.Message); } } private AuthenticatedSiteUser RequireCurrentUser() => AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal()) ?? throw new HubException("La session utilisateur est incomplete."); private async Task SendPresenceSnapshotAsync(string subject, CancellationToken cancellationToken) { var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, cancellationToken); var onlineSubjects = relevantSubjects .Where(_tracker.IsOnline) .Distinct(StringComparer.Ordinal) .ToArray(); await Clients.Caller.SendAsync( "PresenceSnapshot", new PresenceSnapshotMessage { OnlineSubjects = onlineSubjects }, cancellationToken); } private Task NotifyPresenceChangedAsync(IReadOnlyList subjects, string changedSubject, bool isOnline) { var distinctSubjects = subjects .Where(subject => !string.IsNullOrWhiteSpace(subject)) .Distinct(StringComparer.Ordinal) .ToArray(); return distinctSubjects.Length == 0 ? Task.CompletedTask : Clients.Users(distinctSubjects).SendAsync( "PresenceChanged", new PresenceChangedMessage { Subject = changedSubject, IsOnline = isOnline, }); } }