Ajoute les amis et les invitations temps reel

This commit is contained in:
2026-04-15 23:08:48 +02:00
parent 9aae4cadc0
commit 8ea6ef8424
18 changed files with 3136 additions and 25 deletions

View File

@@ -10,6 +10,7 @@
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject HttpClient Http
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Application</PageTitle>
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
@@ -180,6 +181,93 @@
<input @bind="Form.BlackName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
</label>
@if (IsAuthenticated)
{
<section class="setup-social-card span-2">
<div class="social-card-head">
<div>
<span class="micro-label">Amis connectes</span>
<strong>Inviter un ami a jouer</strong>
</div>
<button class="button ghost small icon-button" type="button" title="Rafraichir les amis" aria-label="Rafraichir les amis" @onclick="LoadSocialOverviewAsync">
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
</button>
</div>
<p class="social-empty">
Choisis rapidement un ami en ligne et decide de quel cote il doit etre pre-rempli.
</p>
@if (!string.IsNullOrWhiteSpace(SocialLoadError))
{
<p class="profile-feedback error">@SocialLoadError</p>
}
@if (!string.IsNullOrWhiteSpace(InviteActionError))
{
<p class="profile-feedback error">@InviteActionError</p>
}
@if (!string.IsNullOrWhiteSpace(Realtime.LastInviteNotice))
{
<div class="social-inline-feedback">
<span>@Realtime.LastInviteNotice</span>
<button class="button ghost small" type="button" @onclick="Realtime.ClearInviteNotice">Masquer</button>
</div>
}
@if (Realtime.OutgoingPlayInvite is not null)
{
<div class="play-invite-pending">
<div>
<span class="micro-label">En attente</span>
<strong>@Realtime.OutgoingPlayInvite.RecipientDisplayName</strong>
<p>Invitation envoyee pour jouer cote @(BuildColorLabel(Realtime.OutgoingPlayInvite.RecipientColor)).</p>
</div>
<button class="button ghost small" type="button" @onclick="CancelOutgoingPlayInviteAsync">
Annuler
</button>
</div>
}
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des amis connectes...</p>
}
else if (OnlineFriends.Length > 0)
{
<div class="play-friends-list">
@foreach (var friend in OnlineFriends)
{
<article class="play-friend-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@friend.DisplayName</strong>
<span class="@BuildPresenceClass(friend.Subject, friend.IsOnline)">@BuildPresenceLabel(friend.Subject, friend.IsOnline)</span>
</div>
<span class="social-item-subtitle">@friend.Username</span>
</div>
<div class="play-friend-actions">
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsWhiteAsync(friend.Subject)">
Ami blanc
</button>
<button class="button ghost small" type="button" disabled="@(Realtime.OutgoingPlayInvite is not null)" @onclick="() => InviteFriendAsBlackAsync(friend.Subject)">
Ami noir
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucun ami connecte. Gere tes amis sur la page utilisateur puis attends qu'ils se connectent.</p>
}
</section>
}
@if (Form.CompetitionMode)
{
<label class="field" id="arbiterField">
@@ -287,12 +375,24 @@
@code {
private SetupFormModel Form { get; set; } = new();
private bool _ready;
private bool IsAuthenticated;
private bool IsSocialLoading;
private int _knownSocialVersion;
private string? ConnectedPlayerName;
private string? SocialLoadError;
private string? InviteActionError;
private SocialOverviewResponse? SocialOverview;
private MatchState? CurrentMatch => Store.Current;
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
private SocialFriendResponse[] OnlineFriends
=> SocialOverview?.Friends
.Where(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline))
.OrderBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray()
?? [];
private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode);
@@ -324,7 +424,9 @@
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
await LoadConnectedPlayerAsync();
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await LoadApplicationContextAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -340,9 +442,26 @@
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadConnectedPlayerAsync);
=> _ = InvokeAsync(LoadApplicationContextAsync);
private async Task LoadConnectedPlayerAsync()
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
ApplyAcceptedPlaySession();
if (IsAuthenticated && _knownSocialVersion != Realtime.SocialVersion)
{
_knownSocialVersion = Realtime.SocialVersion;
await LoadSocialOverviewAsync();
return;
}
StateHasChanged();
}
private async Task LoadApplicationContextAsync()
{
string? fallbackName = null;
@@ -353,19 +472,30 @@
if (user.Identity?.IsAuthenticated != true)
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
}
IsAuthenticated = true;
fallbackName = BuildConnectedPlayerFallback(user);
var response = await Http.GetAsync("api/users/me");
if (!response.IsSuccessStatusCode)
{
ConnectedPlayerName = response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
? null
: fallbackName;
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
IsAuthenticated = false;
ConnectedPlayerName = null;
ResetSocialState();
await InvokeAsync(StateHasChanged);
return;
}
ConnectedPlayerName = fallbackName;
await LoadSocialOverviewAsync();
await InvokeAsync(StateHasChanged);
return;
@@ -375,15 +505,62 @@
ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName)
? profile.DisplayName
: fallbackName;
await LoadSocialOverviewAsync();
}
catch
{
ConnectedPlayerName = fallbackName;
}
ApplyAcceptedPlaySession();
await InvokeAsync(StateHasChanged);
}
private async Task LoadSocialOverviewAsync()
{
if (!IsAuthenticated)
{
ResetSocialState();
return;
}
IsSocialLoading = true;
SocialLoadError = null;
try
{
var response = await Http.GetAsync("api/social/overview");
if (!response.IsSuccessStatusCode)
{
SocialLoadError = response.StatusCode switch
{
HttpStatusCode.Unauthorized => "La session a expire. Reconnecte-toi puis recharge la page.",
_ => "Le chargement des amis a echoue.",
};
SocialOverview = null;
return;
}
SocialOverview = await response.Content.ReadFromJsonAsync<SocialOverviewResponse>() ?? new SocialOverviewResponse();
_knownSocialVersion = Realtime.SocialVersion;
}
catch (HttpRequestException)
{
SocialLoadError = "Le service social est temporairement indisponible.";
SocialOverview = null;
}
catch (TaskCanceledException)
{
SocialLoadError = "Le chargement des amis a pris trop de temps.";
SocialOverview = null;
}
finally
{
IsSocialLoading = false;
StateHasChanged();
}
}
private async Task HandleSubmit()
{
await Store.EnsureLoadedAsync();
@@ -426,6 +603,48 @@
Form.BlackName = ConnectedPlayerName!;
}
private async Task InviteFriendToPlayAsync(string friendSubject, string recipientColor)
{
InviteActionError = null;
try
{
await Realtime.SendPlayInviteAsync(friendSubject, recipientColor);
}
catch (InvalidOperationException exception)
{
InviteActionError = exception.Message;
}
catch (Exception exception)
{
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
? "L'invitation de partie n'a pas pu etre envoyee."
: exception.Message;
}
}
private async Task CancelOutgoingPlayInviteAsync()
{
InviteActionError = null;
try
{
await Realtime.CancelOutgoingPlayInviteAsync();
}
catch (Exception exception)
{
InviteActionError = string.IsNullOrWhiteSpace(exception.Message)
? "L'invitation de partie n'a pas pu etre annulee."
: exception.Message;
}
}
private Task InviteFriendAsWhiteAsync(string friendSubject)
=> InviteFriendToPlayAsync(friendSubject, "white");
private Task InviteFriendAsBlackAsync(string friendSubject)
=> InviteFriendToPlayAsync(friendSubject, "black");
private void SetMode(string mode)
=> Form.Mode = mode;
@@ -470,6 +689,44 @@
private string BuildPrefillTitle(string color)
=> $"Utiliser mon nom cote {color}";
private void ApplyAcceptedPlaySession()
{
var session = Realtime.TakeAcceptedPlaySession();
if (session is null)
{
return;
}
Form.WhiteName = session.WhiteName;
Form.BlackName = session.BlackName;
}
private bool ResolveOnlineStatus(string subject, bool fallbackStatus)
=> Realtime.GetKnownOnlineStatus(subject) ?? fallbackStatus;
private string BuildPresenceClass(string subject, bool fallbackStatus)
=> ResolveOnlineStatus(subject, fallbackStatus)
? "presence-badge online"
: "presence-badge";
private string BuildPresenceLabel(string subject, bool fallbackStatus)
=> ResolveOnlineStatus(subject, fallbackStatus)
? "En ligne"
: "Hors ligne";
private static string BuildColorLabel(string color)
=> string.Equals(color, "white", StringComparison.OrdinalIgnoreCase)
? "blanc"
: "noir";
private void ResetSocialState()
{
SocialOverview = null;
SocialLoadError = null;
InviteActionError = null;
_knownSocialVersion = Realtime.SocialVersion;
}
private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user)
=> FirstNonEmpty(
user.FindFirst("name")?.Value,
@@ -481,5 +738,8 @@
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
Realtime.Changed -= HandleRealtimeChanged;
}
}