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

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,61 @@
@implements IDisposable
@inject SocialRealtimeService Realtime
@inject NavigationManager Navigation
@if (Realtime.IncomingPlayInvite is not null)
{
<div class="play-overlay">
<div class="modal-backdrop"></div>
<section class="modal-card play-overlay-card" role="dialog" aria-modal="true" aria-labelledby="playInviteTitle">
<div class="modal-head">
<div>
<p class="eyebrow">Invitation de partie</p>
<h2 id="playInviteTitle">@Realtime.IncomingPlayInvite.SenderDisplayName veut jouer</h2>
</div>
</div>
<p class="play-overlay-copy">
@Realtime.IncomingPlayInvite.SenderDisplayName te propose une partie ChessCubing et te place cote @RecipientColorLabel.
</p>
<div class="play-overlay-actions">
<button class="button secondary small" type="button" @onclick="AcceptAsync">Accepter</button>
<button class="button ghost small" type="button" @onclick="DeclineAsync">Refuser</button>
</div>
</section>
</div>
}
@code {
private string RecipientColorLabel
=> string.Equals(Realtime.IncomingPlayInvite?.RecipientColor, "white", StringComparison.Ordinal)
? "blanc"
: "noir";
protected override async Task OnInitializedAsync()
{
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
}
private void HandleRealtimeChanged()
=> _ = InvokeAsync(StateHasChanged);
private async Task AcceptAsync()
{
await Realtime.RespondToIncomingPlayInviteAsync(accept: true);
var currentPath = new Uri(Navigation.Uri).AbsolutePath.Trim('/');
if (!string.Equals(currentPath, "application", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(currentPath, "application.html", StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo("/application.html");
}
}
private Task DeclineAsync()
=> Realtime.RespondToIncomingPlayInviteAsync(accept: false);
public void Dispose()
=> Realtime.Changed -= HandleRealtimeChanged;
}

View File

@@ -13,6 +13,8 @@ else
@Body
}
<PlayInviteOverlay />
@code {
private bool HideGlobalMenu
{

View File

@@ -0,0 +1,135 @@
namespace ChessCubing.App.Models.Social;
public sealed class SocialOverviewResponse
{
public SocialFriendResponse[] Friends { get; init; } = [];
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
}
public sealed class SocialFriendResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
}
public sealed class SocialInvitationResponse
{
public long InvitationId { get; init; }
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public bool IsOnline { get; init; }
public DateTime CreatedUtc { get; init; }
}
public sealed class SocialSearchUserResponse
{
public string Subject { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Club { get; init; }
public string? City { get; init; }
public bool IsOnline { get; init; }
public bool IsFriend { get; init; }
public bool HasSentInvitation { get; init; }
public bool HasReceivedInvitation { get; init; }
}
public sealed class SendFriendInvitationRequest
{
public string TargetSubject { get; init; } = string.Empty;
}
public sealed class PresenceSnapshotMessage
{
public string[] OnlineSubjects { get; init; } = [];
}
public sealed class PresenceChangedMessage
{
public string Subject { get; init; } = string.Empty;
public bool IsOnline { get; init; }
}
public sealed class PlayInviteMessage
{
public string InviteId { get; init; } = string.Empty;
public string SenderSubject { get; init; } = string.Empty;
public string SenderUsername { get; init; } = string.Empty;
public string SenderDisplayName { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public string RecipientUsername { get; init; } = string.Empty;
public string RecipientDisplayName { get; init; } = string.Empty;
public string RecipientColor { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
public DateTime ExpiresUtc { get; init; }
}
public sealed class PlayInviteClosedMessage
{
public string InviteId { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
public sealed class PlaySessionResponse
{
public string SessionId { get; init; } = string.Empty;
public string WhiteSubject { get; init; } = string.Empty;
public string WhiteName { get; init; } = string.Empty;
public string BlackSubject { get; init; } = string.Empty;
public string BlackName { get; init; } = string.Empty;
public string InitiatorSubject { get; init; } = string.Empty;
public string RecipientSubject { get; init; } = string.Empty;
public DateTime ConfirmedUtc { get; init; }
}

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;
}
}

View File

@@ -7,6 +7,7 @@
@implements IDisposable
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject HttpClient Http
@inject SocialRealtimeService Realtime
<PageTitle>ChessCubing Arena | Utilisateur</PageTitle>
<PageBody BodyClass="home-body" />
@@ -18,12 +19,11 @@
<header class="hero hero-home user-hero">
<div class="hero-copy">
<p class="eyebrow">Espace utilisateur</p>
<h1>Gerer les donnees du site pour chaque joueur</h1>
<h1>Profil joueur, amis et invitations</h1>
<p class="lead">
Cette page relie le compte connecte a une fiche utilisateur stockee
cote serveur en MySQL. L'authentification reste geree par Keycloak,
mais les informations metier du site sont maintenant pretes pour des
evolutions futures.
Cette page regroupe le profil du site, la gestion des amis ChessCubing,
ainsi que les invitations recues ou envoyees. Les confirmations de partie
sont ensuite synchronisees en direct avec SignalR.
</p>
<div class="hero-actions">
<a class="button secondary" href="application.html">Ouvrir l'application</a>
@@ -33,11 +33,11 @@
<aside class="hero-preview">
<div class="preview-card">
<p class="micro-label">Persistance</p>
<strong>Profil du site stocke en MySQL</strong>
<p class="micro-label">Temps reel</p>
<strong>Presence et invitations synchronisees</strong>
<p>
Username et email restent lies au compte authentifie, pendant que
le profil ChessCubing ajoute les donnees utiles au site.
Les amis connectes apparaissent en direct, et les invitations de
partie peuvent etre confirmees depuis un autre device.
</p>
</div>
<div class="preview-banner">
@@ -55,11 +55,12 @@
<div class="section-heading">
<div>
<p class="eyebrow">Connexion requise</p>
<h2>Connecte-toi pour gerer ton profil</h2>
<h2>Connecte-toi pour gerer ton profil et tes amis</h2>
</div>
<p class="section-copy">
Utilise les boutons Se connecter ou Creer un compte dans le menu
en haut de page, puis reviens ici pour enregistrer tes informations.
en haut de page, puis reviens ici pour enregistrer tes informations
et inviter d'autres joueurs.
</p>
</div>
@@ -86,15 +87,15 @@
{
<section class="panel panel-wide">
<p class="eyebrow">Chargement</p>
<h2>Recuperation du profil utilisateur</h2>
<p class="section-copy">Le serveur recharge les donnees enregistrees pour ce compte.</p>
<h2>Recuperation de l'espace utilisateur</h2>
<p class="section-copy">Le serveur recharge le profil, les amis et les invitations de ce compte.</p>
</section>
}
else if (!string.IsNullOrWhiteSpace(LoadError))
{
<section class="panel panel-wide">
<p class="eyebrow">Serveur</p>
<h2>Impossible de charger le profil</h2>
<h2>Impossible de charger l'espace utilisateur</h2>
<p class="profile-feedback error">@LoadError</p>
<div class="hero-actions">
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
@@ -217,6 +218,235 @@
</div>
</EditForm>
</section>
<section class="panel panel-wide user-social-panel">
<div class="section-heading user-profile-heading">
<div>
<p class="eyebrow">Reseau joueur</p>
<h2>Amis et invitations</h2>
</div>
<div class="hero-actions">
<button class="button ghost small icon-button" type="button" title="Rafraichir" aria-label="Rafraichir" @onclick="ReloadSocialAsync">
<span class="material-icons action-icon" aria-hidden="true">refresh</span>
</button>
</div>
</div>
<p class="user-profile-note">Invite des joueurs du site, suis les invitations en cours et retrouve rapidement les amis connectes.</p>
@if (!string.IsNullOrWhiteSpace(SocialLoadError))
{
<p class="profile-feedback error">@SocialLoadError</p>
}
@if (!string.IsNullOrWhiteSpace(SocialActionError))
{
<p class="profile-feedback error">@SocialActionError</p>
}
@if (!string.IsNullOrWhiteSpace(SocialActionMessage))
{
<p class="profile-feedback success">@SocialActionMessage</p>
}
<form class="social-search-form" @onsubmit="HandleSearchSubmit" @onsubmit:preventDefault="true">
<label class="field">
<span>Rechercher un joueur</span>
<input @bind="SearchQuery" @bind:event="oninput" type="text" maxlength="80" placeholder="Nom, pseudo, club, ville..." />
</label>
<div class="social-search-actions">
<button class="button secondary small" type="submit" disabled="@IsSearching">
@(IsSearching ? "Recherche..." : "Chercher")
</button>
</div>
</form>
@if (!string.IsNullOrWhiteSpace(SearchError))
{
<p class="profile-feedback error">@SearchError</p>
}
@if (SearchResults.Length > 0)
{
<div class="social-search-results">
@foreach (var result in SearchResults)
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@result.DisplayName</strong>
<span class="@BuildPresenceClass(result.Subject, result.IsOnline)">@BuildPresenceLabel(result.Subject, result.IsOnline)</span>
</div>
<span class="social-item-subtitle">@result.Username</span>
@if (!string.IsNullOrWhiteSpace(result.Club) || !string.IsNullOrWhiteSpace(result.City))
{
<span class="social-item-caption">@JoinNonEmpty(result.Club, result.City)</span>
}
</div>
<div class="social-item-actions">
@if (result.IsFriend)
{
<span class="mini-chip admin-chip-neutral">Deja ami</span>
}
else if (FindReceivedInvitation(result.Subject) is { } receivedInvitation)
{
<button class="button secondary small" type="button" @onclick="() => AcceptInvitationAsync(receivedInvitation.InvitationId)">
Accepter
</button>
<button class="button ghost small" type="button" @onclick="() => DeclineInvitationAsync(receivedInvitation.InvitationId)">
Refuser
</button>
}
else if (FindSentInvitation(result.Subject) is { } sentInvitation)
{
<span class="mini-chip admin-chip-outline">Invitation envoyee</span>
<button class="button ghost small" type="button" @onclick="() => CancelInvitationAsync(sentInvitation.InvitationId)">
Annuler
</button>
}
else
{
<button class="button secondary small" type="button" @onclick="() => SendInvitationAsync(result.Subject)">
Inviter
</button>
}
</div>
</article>
}
</div>
}
<div class="social-columns">
<section class="social-card">
<div class="social-card-head">
<strong>Amis</strong>
<span class="mini-chip admin-chip-outline">@FriendCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des amis...</p>
}
else if (SocialOverview?.Friends.Length > 0)
{
<div class="social-list">
@foreach (var friend in SocialOverview.Friends.OrderByDescending(friend => ResolveOnlineStatus(friend.Subject, friend.IsOnline)).ThenBy(friend => friend.DisplayName, StringComparer.OrdinalIgnoreCase))
{
<article class="social-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>
@if (!string.IsNullOrWhiteSpace(friend.Club) || !string.IsNullOrWhiteSpace(friend.City))
{
<span class="social-item-caption">@JoinNonEmpty(friend.Club, friend.City)</span>
}
</div>
<div class="social-item-actions">
<button class="button ghost small" type="button" @onclick="() => RemoveFriendAsync(friend.Subject)">
Retirer
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucun ami pour le moment. Utilise la recherche ci-dessus pour commencer.</p>
}
</section>
<section class="social-card">
<div class="social-card-head">
<strong>Invitations recues</strong>
<span class="mini-chip admin-chip-outline">@ReceivedCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des invitations...</p>
}
else if (SocialOverview?.ReceivedInvitations.Length > 0)
{
<div class="social-list">
@foreach (var invitation in SocialOverview.ReceivedInvitations.OrderByDescending(invitation => invitation.CreatedUtc))
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@invitation.DisplayName</strong>
<span class="@BuildPresenceClass(invitation.Subject, invitation.IsOnline)">@BuildPresenceLabel(invitation.Subject, invitation.IsOnline)</span>
</div>
<span class="social-item-subtitle">@invitation.Username</span>
<span class="social-item-caption">Recue le @FormatDate(invitation.CreatedUtc)</span>
</div>
<div class="social-item-actions">
<button class="button secondary small" type="button" @onclick="() => AcceptInvitationAsync(invitation.InvitationId)">
Accepter
</button>
<button class="button ghost small" type="button" @onclick="() => DeclineInvitationAsync(invitation.InvitationId)">
Refuser
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune invitation recue pour l'instant.</p>
}
</section>
<section class="social-card">
<div class="social-card-head">
<strong>Invitations envoyees</strong>
<span class="mini-chip admin-chip-outline">@SentCountLabel</span>
</div>
@if (IsSocialLoading)
{
<p class="social-empty">Chargement des invitations...</p>
}
else if (SocialOverview?.SentInvitations.Length > 0)
{
<div class="social-list">
@foreach (var invitation in SocialOverview.SentInvitations.OrderByDescending(invitation => invitation.CreatedUtc))
{
<article class="social-item">
<div class="social-item-meta">
<div class="social-item-title-row">
<strong>@invitation.DisplayName</strong>
<span class="@BuildPresenceClass(invitation.Subject, invitation.IsOnline)">@BuildPresenceLabel(invitation.Subject, invitation.IsOnline)</span>
</div>
<span class="social-item-subtitle">@invitation.Username</span>
<span class="social-item-caption">Envoyee le @FormatDate(invitation.CreatedUtc)</span>
</div>
<div class="social-item-actions">
<button class="button ghost small" type="button" @onclick="() => CancelInvitationAsync(invitation.InvitationId)">
Annuler
</button>
</div>
</article>
}
</div>
}
else
{
<p class="social-empty">Aucune invitation envoyee pour l'instant.</p>
}
</section>
</div>
</section>
}
</main>
</div>
@@ -225,12 +455,22 @@
private readonly UserProfileFormModel Form = new();
private UserProfileResponse? Profile;
private SocialOverviewResponse? SocialOverview;
private SocialSearchUserResponse[] SearchResults = [];
private bool IsAuthenticated;
private bool IsLoading = true;
private bool IsSaving;
private bool IsSocialLoading;
private bool IsSearching;
private int _knownSocialVersion;
private string? LoadError;
private string? SaveError;
private string? SaveMessage;
private string? SocialLoadError;
private string? SocialActionError;
private string? SocialActionMessage;
private string? SearchError;
private string SearchQuery = string.Empty;
private string HeroStatusTitle
=> !IsAuthenticated
@@ -241,20 +481,55 @@
private string HeroStatusDescription
=> !IsAuthenticated
? "Le profil du site apparait des qu'un compte joueur est connecte."
? "Le profil du site et le reseau d'amis apparaissent des qu'un compte joueur est connecte."
: IsLoading
? "Le serveur verifie la fiche utilisateur associee a ce compte."
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} et stocke en base MySQL.";
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} avec synchronisation sociale en direct.";
private string FriendCountLabel => $"{SocialOverview?.Friends.Length ?? 0} ami(s)";
private string ReceivedCountLabel => $"{SocialOverview?.ReceivedInvitations.Length ?? 0} recue(s)";
private string SentCountLabel => $"{SocialOverview?.SentInvitations.Length ?? 0} envoyee(s)";
protected override async Task OnInitializedAsync()
{
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
Realtime.Changed += HandleRealtimeChanged;
await Realtime.EnsureStartedAsync();
await LoadProfileAsync();
}
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
=> _ = InvokeAsync(LoadProfileAsync);
private void HandleRealtimeChanged()
=> _ = InvokeAsync(HandleRealtimeChangedAsync);
private async Task HandleRealtimeChangedAsync()
{
if (!IsAuthenticated)
{
StateHasChanged();
return;
}
if (_knownSocialVersion != Realtime.SocialVersion)
{
_knownSocialVersion = Realtime.SocialVersion;
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
return;
}
StateHasChanged();
}
private async Task LoadProfileAsync()
{
LoadError = null;
@@ -268,6 +543,7 @@
if (authState.User.Identity?.IsAuthenticated != true)
{
ResetProfileState();
ResetSocialState();
return;
}
@@ -277,6 +553,7 @@
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
ResetProfileState();
ResetSocialState();
return;
}
@@ -284,6 +561,7 @@
{
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
Profile = null;
ResetSocialState();
return;
}
@@ -291,20 +569,24 @@
if (Profile is null)
{
LoadError = "Le serveur a retourne une reponse vide.";
ResetSocialState();
return;
}
FillForm(Profile);
await LoadSocialOverviewAsync();
}
catch (HttpRequestException)
{
LoadError = "Le service utilisateur est temporairement indisponible.";
Profile = null;
ResetSocialState();
}
catch (TaskCanceledException)
{
LoadError = "La reponse du service utilisateur a pris trop de temps.";
Profile = null;
ResetSocialState();
}
finally
{
@@ -313,6 +595,43 @@
}
}
private async Task LoadSocialOverviewAsync()
{
SocialLoadError = null;
IsSocialLoading = true;
try
{
var response = await Http.GetAsync("api/social/overview");
if (!response.IsSuccessStatusCode)
{
SocialLoadError = await ReadErrorAsync(response, "Le reseau social n'a pas pu etre charge.");
SocialOverview = null;
SearchResults = [];
return;
}
SocialOverview = await response.Content.ReadFromJsonAsync<SocialOverviewResponse>();
SocialOverview ??= new SocialOverviewResponse();
_knownSocialVersion = Realtime.SocialVersion;
}
catch (HttpRequestException)
{
SocialLoadError = "Le service social est temporairement indisponible.";
SocialOverview = null;
}
catch (TaskCanceledException)
{
SocialLoadError = "Le chargement du reseau social a pris trop de temps.";
SocialOverview = null;
}
finally
{
IsSocialLoading = false;
StateHasChanged();
}
}
private async Task SaveProfileAsync()
{
if (IsSaving || !IsAuthenticated)
@@ -367,6 +686,165 @@
}
}
private Task HandleSearchSubmit()
=> SearchUsersAsync();
private async Task SearchUsersAsync()
{
SearchError = null;
SocialActionError = null;
SocialActionMessage = null;
var normalizedQuery = SearchQuery.Trim();
if (string.IsNullOrWhiteSpace(normalizedQuery))
{
SearchResults = [];
StateHasChanged();
return;
}
IsSearching = true;
try
{
var response = await Http.GetAsync($"api/social/search?query={Uri.EscapeDataString(normalizedQuery)}");
if (!response.IsSuccessStatusCode)
{
SearchError = await ReadErrorAsync(response, "La recherche de joueurs a echoue.");
SearchResults = [];
return;
}
SearchResults = await response.Content.ReadFromJsonAsync<SocialSearchUserResponse[]>() ?? [];
}
catch (HttpRequestException)
{
SearchError = "Le service social est temporairement indisponible.";
SearchResults = [];
}
catch (TaskCanceledException)
{
SearchError = "La recherche a pris trop de temps.";
SearchResults = [];
}
finally
{
IsSearching = false;
StateHasChanged();
}
}
private async Task ReloadSocialAsync()
{
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
}
private async Task SendInvitationAsync(string subject)
{
await RunSocialActionAsync(
async () =>
{
var payload = new SendFriendInvitationRequest { TargetSubject = subject };
var response = await Http.PostAsJsonAsync("api/social/invitations", payload);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre envoyee."));
}
},
"Invitation d'ami envoyee.");
}
private async Task AcceptInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.PostAsync($"api/social/invitations/{invitationId}/accept", content: null);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre acceptee."));
}
},
"Invitation acceptee. Le joueur a ete ajoute aux amis.");
}
private async Task DeclineInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.PostAsync($"api/social/invitations/{invitationId}/decline", content: null);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre refusee."));
}
},
"Invitation refusee.");
}
private async Task CancelInvitationAsync(long invitationId)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.DeleteAsync($"api/social/invitations/{invitationId}");
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'invitation n'a pas pu etre annulee."));
}
},
"Invitation annulee.");
}
private async Task RemoveFriendAsync(string friendSubject)
{
await RunSocialActionAsync(
async () =>
{
var response = await Http.DeleteAsync($"api/social/friends/{Uri.EscapeDataString(friendSubject)}");
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await ReadErrorAsync(response, "L'ami n'a pas pu etre retire."));
}
},
"Relation d'amitie retiree.");
}
private async Task RunSocialActionAsync(Func<Task> action, string successMessage)
{
SocialActionError = null;
SocialActionMessage = null;
try
{
await action();
SocialActionMessage = successMessage;
await LoadSocialOverviewAsync();
if (!string.IsNullOrWhiteSpace(SearchQuery))
{
await SearchUsersAsync();
}
}
catch (InvalidOperationException exception)
{
SocialActionError = exception.Message;
}
catch (HttpRequestException)
{
SocialActionError = "Le service social est temporairement indisponible.";
}
catch (TaskCanceledException)
{
SocialActionError = "L'action sociale a pris trop de temps.";
}
}
private void ResetProfileState()
{
IsAuthenticated = false;
@@ -374,6 +852,18 @@
Form.Reset();
}
private void ResetSocialState()
{
SocialOverview = null;
SearchResults = [];
SearchQuery = string.Empty;
SearchError = null;
SocialLoadError = null;
SocialActionError = null;
SocialActionMessage = null;
_knownSocialVersion = Realtime.SocialVersion;
}
private void FillForm(UserProfileResponse profile)
{
Form.DisplayName = profile.DisplayName;
@@ -384,6 +874,28 @@
Form.Bio = profile.Bio;
}
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 SocialInvitationResponse? FindReceivedInvitation(string subject)
=> SocialOverview?.ReceivedInvitations.FirstOrDefault(invitation => string.Equals(invitation.Subject, subject, StringComparison.Ordinal));
private SocialInvitationResponse? FindSentInvitation(string subject)
=> SocialOverview?.SentInvitations.FirstOrDefault(invitation => string.Equals(invitation.Subject, subject, StringComparison.Ordinal));
private static string JoinNonEmpty(params string?[] values)
=> string.Join(" • ", values.Where(value => !string.IsNullOrWhiteSpace(value)));
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
{
try
@@ -409,7 +921,10 @@
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
public void Dispose()
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
{
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
Realtime.Changed -= HandleRealtimeChanged;
}
private sealed class ApiErrorMessage
{

View File

@@ -15,5 +15,6 @@ builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredServ
builder.Services.AddScoped<BrowserBridge>();
builder.Services.AddScoped<UserSession>();
builder.Services.AddScoped<MatchStore>();
builder.Services.AddScoped<SocialRealtimeService>();
await builder.Build().RunAsync();

View File

@@ -0,0 +1,329 @@
using System.Security.Claims;
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 readonly NavigationManager _navigation = navigation;
private readonly AuthenticationStateProvider _authenticationStateProvider = authenticationStateProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private HubConnection? _hubConnection;
private string? _currentSubject;
private HashSet<string> _knownPresenceSubjects = new(StringComparer.Ordinal);
private HashSet<string> _onlineSubjects = new(StringComparer.Ordinal);
private PlayInviteMessage? _incomingPlayInvite;
private PlayInviteMessage? _outgoingPlayInvite;
private PlaySessionResponse? _activePlaySession;
private string? _lastInviteNotice;
private int _socialVersion;
private bool _started;
public event Action? Changed;
public PlayInviteMessage? IncomingPlayInvite => _incomingPlayInvite;
public PlayInviteMessage? OutgoingPlayInvite => _outgoingPlayInvite;
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 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 PlaySessionResponse? TakeAcceptedPlaySession()
{
var session = _activePlaySession;
_activePlaySession = null;
return session;
}
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);
if (string.IsNullOrWhiteSpace(subject))
{
await DisconnectUnsafeAsync();
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();
_currentSubject = subject;
_hubConnection = BuildHubConnection();
RegisterHandlers(_hubConnection);
await _hubConnection.StartAsync();
}
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<PlaySessionResponse>("PlayInviteAccepted", session =>
{
_incomingPlayInvite = null;
_outgoingPlayInvite = null;
_activePlaySession = session;
_lastInviteNotice = "La partie est confirmee. Les noms ont ete pre-remplis dans l'application.";
RaiseChanged();
});
connection.Reconnected += async _ =>
{
await RequestPresenceSnapshotAsync();
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 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 async Task DisconnectUnsafeAsync()
{
_currentSubject = null;
_knownPresenceSubjects.Clear();
_onlineSubjects.Clear();
_incomingPlayInvite = null;
_outgoingPlayInvite = null;
_activePlaySession = 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;
}

View File

@@ -3,6 +3,7 @@
@using ChessCubing.App
@using ChessCubing.App.Components
@using ChessCubing.App.Models
@using ChessCubing.App.Models.Social
@using ChessCubing.App.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization