470 lines
14 KiB
C#
470 lines
14 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ChessCubing.App.Models;
|
|
using ChessCubing.App.Models.Social;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.SignalR.Client;
|
|
|
|
namespace ChessCubing.App.Services;
|
|
|
|
public sealed class SocialRealtimeService(
|
|
NavigationManager navigation,
|
|
AuthenticationStateProvider authenticationStateProvider) : IAsyncDisposable
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
private readonly NavigationManager _navigation = navigation;
|
|
private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider;
|
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
|
|
|
private HubConnection? _hubConnection;
|
|
private string? _currentSubject;
|
|
private string? _joinedPlaySessionId;
|
|
private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal);
|
|
private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal);
|
|
private PlayInviteMessage? _incomingPlayInvite;
|
|
private PlayInviteMessage? _outgoingPlayInvite;
|
|
private PlaySessionResponse? _activePlaySession;
|
|
private CollaborativeMatchSnapshot? _collaborativeSnapshot;
|
|
private string? _lastInviteNotice;
|
|
private int _socialVersion;
|
|
private bool _started;
|
|
|
|
public event Action? Changed;
|
|
|
|
public PlayInviteMessage? IncomingPlayInvite => _incomingPlayInvite;
|
|
|
|
public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite;
|
|
|
|
public PlaySessionResponse? ActivePlaySession => _activePlaySession;
|
|
|
|
public CollaborativeMatchSnapshot? CollaborativeSnapshot => _collaborativeSnapshot;
|
|
|
|
public string? LastInviteNotice => _lastInviteNotice;
|
|
|
|
public int SocialVersion => _socialVersion;
|
|
|
|
public async Task EnsureStartedAsync()
|
|
{
|
|
if (_started)
|
|
{
|
|
await SyncConnectionAsync();
|
|
return;
|
|
}
|
|
|
|
_started = true;
|
|
_authenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
|
await SyncConnectionAsync();
|
|
}
|
|
|
|
public bool IsUserOnline(string subject)
|
|
=> _onlineSubjects.Contains(subject);
|
|
|
|
public bool? GetKnownOnlineStatus(string subject)
|
|
{
|
|
if (_onlineSubjects.Contains(subject))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return _knownPresenceSubjects.Contains(subject)
|
|
? false
|
|
: null;
|
|
}
|
|
|
|
public async Task EnsureJoinedPlaySessionAsync(string? sessionId)
|
|
{
|
|
var normalizedSessionId = sessionId?.Trim();
|
|
if (string.IsNullOrWhiteSpace(normalizedSessionId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await EnsureStartedAsync();
|
|
await JoinPlaySessionCoreAsync(normalizedSessionId);
|
|
}
|
|
|
|
public async Task PublishMatchStateAsync(MatchState? match, string route)
|
|
{
|
|
var sessionId = match?.CollaborationSessionId
|
|
?? _activePlaySession?.SessionId
|
|
?? _joinedPlaySessionId;
|
|
|
|
if (string.IsNullOrWhiteSpace(sessionId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await EnsureJoinedPlaySessionAsync(sessionId);
|
|
|
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
|
{
|
|
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
|
|
}
|
|
|
|
var payload = match is null
|
|
? null
|
|
: JsonSerializer.Serialize(match, JsonOptions);
|
|
|
|
await _hubConnection.InvokeAsync("PublishMatchState", sessionId, payload, route);
|
|
}
|
|
|
|
public async Task SendPlayInviteAsync(string recipientSubject, string recipientColor)
|
|
{
|
|
_lastInviteNotice = null;
|
|
|
|
if (_hubConnection is null)
|
|
{
|
|
throw new InvalidOperationException("La connexion temps reel n'est pas prete.");
|
|
}
|
|
|
|
var invite = await _hubConnection.InvokeAsync<PlayInviteMessage>("SendPlayInvite", recipientSubject, recipientColor);
|
|
ApplyInvite(invite);
|
|
RaiseChanged();
|
|
}
|
|
|
|
public async Task RespondToIncomingPlayInviteAsync(bool accept)
|
|
{
|
|
if (_hubConnection is null || _incomingPlayInvite is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var inviteId = _incomingPlayInvite.InviteId;
|
|
await _hubConnection.InvokeAsync("RespondToPlayInvite", inviteId, accept);
|
|
|
|
if (!accept)
|
|
{
|
|
_incomingPlayInvite = null;
|
|
RaiseChanged();
|
|
}
|
|
}
|
|
|
|
public async Task CancelOutgoingPlayInviteAsync()
|
|
{
|
|
if (_hubConnection is null || _outgoingPlayInvite is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var inviteId = _outgoingPlayInvite.InviteId;
|
|
await _hubConnection.InvokeAsync("CancelPlayInvite", inviteId);
|
|
}
|
|
|
|
public void ClearInviteNotice()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_lastInviteNotice))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastInviteNotice = null;
|
|
RaiseChanged();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_authenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
|
|
|
await _gate.WaitAsync();
|
|
try
|
|
{
|
|
if (_hubConnection is not null)
|
|
{
|
|
await _hubConnection.DisposeAsync();
|
|
_hubConnection = null;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
_gate.Dispose();
|
|
}
|
|
}
|
|
|
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
|
=> _ = SyncConnectionAsync();
|
|
|
|
private async Task SyncConnectionAsync()
|
|
{
|
|
await _gate.WaitAsync();
|
|
try
|
|
{
|
|
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
|
|
var user = authState.User;
|
|
var subject = ResolveSubject(user);
|
|
var preserveSessionState = string.Equals(subject, _currentSubject, StringComparison.Ordinal);
|
|
|
|
if (string.IsNullOrWhiteSpace(subject))
|
|
{
|
|
await DisconnectUnsafeAsync(clearSessionState: true);
|
|
return;
|
|
}
|
|
|
|
if (_hubConnection is not null &&
|
|
string.Equals(subject, _currentSubject, StringComparison.Ordinal) &&
|
|
_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await DisconnectUnsafeAsync(clearSessionState: !preserveSessionState);
|
|
|
|
_currentSubject = subject;
|
|
_hubConnection = BuildHubConnection();
|
|
RegisterHandlers(_hubConnection);
|
|
await _hubConnection.StartAsync();
|
|
|
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
|
|
{
|
|
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_gate.Release();
|
|
}
|
|
}
|
|
|
|
private HubConnection BuildHubConnection()
|
|
=> new HubConnectionBuilder()
|
|
.WithUrl(_navigation.ToAbsoluteUri("/hubs/social"))
|
|
.WithAutomaticReconnect()
|
|
.Build();
|
|
|
|
private void RegisterHandlers(HubConnection connection)
|
|
{
|
|
connection.On<PresenceSnapshotMessage>("PresenceSnapshot", message =>
|
|
{
|
|
_knownPresenceSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
|
|
_onlineSubjects = message.OnlineSubjects.ToHashSet(StringComparer.Ordinal);
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On<PresenceChangedMessage>("PresenceChanged", message =>
|
|
{
|
|
_knownPresenceSubjects.Add(message.Subject);
|
|
|
|
if (message.IsOnline)
|
|
{
|
|
_onlineSubjects.Add(message.Subject);
|
|
}
|
|
else
|
|
{
|
|
_onlineSubjects.Remove(message.Subject);
|
|
}
|
|
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On("SocialChanged", async () =>
|
|
{
|
|
Interlocked.Increment(ref _socialVersion);
|
|
await RequestPresenceSnapshotAsync();
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On<PlayInviteMessage>("PlayInviteUpdated", message =>
|
|
{
|
|
ApplyInvite(message);
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On<PlayInviteClosedMessage>("PlayInviteClosed", message =>
|
|
{
|
|
if (_incomingPlayInvite?.InviteId == message.InviteId)
|
|
{
|
|
_incomingPlayInvite = null;
|
|
}
|
|
|
|
if (_outgoingPlayInvite?.InviteId == message.InviteId)
|
|
{
|
|
_outgoingPlayInvite = null;
|
|
}
|
|
|
|
_lastInviteNotice = message.Message;
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On<CollaborativeMatchStateMessage>("CollaborativeMatchStateUpdated", message =>
|
|
{
|
|
ApplyCollaborativeState(message);
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.On<PlaySessionResponse>("PlayInviteAccepted", async session =>
|
|
{
|
|
_incomingPlayInvite = null;
|
|
_outgoingPlayInvite = null;
|
|
_activePlaySession = session;
|
|
_lastInviteNotice = "La partie est confirmee. Les deux ecrans resteront synchronises pendant le match.";
|
|
await JoinPlaySessionCoreAsync(session.SessionId);
|
|
RaiseChanged();
|
|
});
|
|
|
|
connection.Reconnected += async _ =>
|
|
{
|
|
await RequestPresenceSnapshotAsync();
|
|
|
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId))
|
|
{
|
|
await JoinPlaySessionCoreAsync(_joinedPlaySessionId);
|
|
}
|
|
|
|
RaiseChanged();
|
|
};
|
|
|
|
connection.Closed += async _ =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(_currentSubject))
|
|
{
|
|
await SyncConnectionAsync();
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task RequestPresenceSnapshotAsync()
|
|
{
|
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _hubConnection.InvokeAsync("RequestPresenceSnapshot");
|
|
}
|
|
catch
|
|
{
|
|
// La vue peut continuer avec le dernier etat connu si le snapshot echoue ponctuellement.
|
|
}
|
|
}
|
|
|
|
private async Task JoinPlaySessionCoreAsync(string sessionId)
|
|
{
|
|
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(_joinedPlaySessionId) &&
|
|
!string.Equals(_joinedPlaySessionId, sessionId, StringComparison.Ordinal))
|
|
{
|
|
try
|
|
{
|
|
await _hubConnection.InvokeAsync("LeavePlaySession", _joinedPlaySessionId);
|
|
}
|
|
catch
|
|
{
|
|
// La prochaine connexion recreera les groupes si besoin.
|
|
}
|
|
}
|
|
|
|
var latestState = await _hubConnection.InvokeAsync<CollaborativeMatchStateMessage?>("JoinPlaySession", sessionId);
|
|
_joinedPlaySessionId = sessionId;
|
|
|
|
if (latestState is not null)
|
|
{
|
|
ApplyCollaborativeState(latestState);
|
|
}
|
|
else if (_collaborativeSnapshot?.SessionId != sessionId)
|
|
{
|
|
_collaborativeSnapshot = null;
|
|
}
|
|
}
|
|
|
|
private void ApplyInvite(PlayInviteMessage invite)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_currentSubject))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(invite.RecipientSubject, _currentSubject, StringComparison.Ordinal))
|
|
{
|
|
_incomingPlayInvite = invite;
|
|
}
|
|
|
|
if (string.Equals(invite.SenderSubject, _currentSubject, StringComparison.Ordinal))
|
|
{
|
|
_outgoingPlayInvite = invite;
|
|
}
|
|
}
|
|
|
|
private void ApplyCollaborativeState(CollaborativeMatchStateMessage message)
|
|
{
|
|
if (_collaborativeSnapshot is not null &&
|
|
string.Equals(_collaborativeSnapshot.SessionId, message.SessionId, StringComparison.Ordinal) &&
|
|
message.Revision <= _collaborativeSnapshot.Revision)
|
|
{
|
|
return;
|
|
}
|
|
|
|
MatchState? match = null;
|
|
if (!string.IsNullOrWhiteSpace(message.MatchJson))
|
|
{
|
|
try
|
|
{
|
|
match = JsonSerializer.Deserialize<MatchState>(message.MatchJson, JsonOptions);
|
|
if (match is not null)
|
|
{
|
|
match.CollaborationSessionId = message.SessionId;
|
|
MatchEngine.NormalizeRecoveredMatch(match);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
match = null;
|
|
}
|
|
}
|
|
|
|
_collaborativeSnapshot = new CollaborativeMatchSnapshot
|
|
{
|
|
SessionId = message.SessionId,
|
|
Match = match,
|
|
Route = string.IsNullOrWhiteSpace(message.Route) ? "/application.html" : message.Route,
|
|
SenderSubject = message.SenderSubject,
|
|
Revision = message.Revision,
|
|
UpdatedUtc = message.UpdatedUtc,
|
|
};
|
|
}
|
|
|
|
private async Task DisconnectUnsafeAsync(bool clearSessionState)
|
|
{
|
|
_currentSubject = clearSessionState ? null : _currentSubject;
|
|
_knownPresenceSubjects.Clear();
|
|
_onlineSubjects.Clear();
|
|
_incomingPlayInvite = null;
|
|
_outgoingPlayInvite = null;
|
|
|
|
if (clearSessionState)
|
|
{
|
|
_activePlaySession = null;
|
|
_joinedPlaySessionId = null;
|
|
_collaborativeSnapshot = null;
|
|
_lastInviteNotice = null;
|
|
}
|
|
|
|
if (_hubConnection is not null)
|
|
{
|
|
await _hubConnection.DisposeAsync();
|
|
_hubConnection = null;
|
|
}
|
|
|
|
RaiseChanged();
|
|
}
|
|
|
|
private void RaiseChanged()
|
|
=> Changed?.Invoke();
|
|
|
|
private static string? ResolveSubject(ClaimsPrincipal user)
|
|
=> user.Identity?.IsAuthenticated == true
|
|
? user.FindFirst("sub")?.Value ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
|
: null;
|
|
}
|