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

View File

@@ -3,10 +3,12 @@ using System.Net.Mail;
using ChessCubing.Server.Admin;
using ChessCubing.Server.Auth;
using ChessCubing.Server.Data;
using ChessCubing.Server.Social;
using ChessCubing.Server.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
@@ -67,15 +69,21 @@ builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient<KeycloakAuthService>();
builder.Services.AddSingleton<MySqlUserProfileStore>();
builder.Services.AddSingleton<MySqlSocialStore>();
builder.Services.AddSingleton<ConnectedUserTracker>();
builder.Services.AddSingleton<PlayInviteCoordinator>();
var app = builder.Build();
await using (var scope = app.Services.CreateAsyncScope())
{
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
await profileStore.InitializeAsync(CancellationToken.None);
await socialStore.InitializeAsync(CancellationToken.None);
}
app.UseAuthentication();
@@ -124,6 +132,169 @@ app.MapPut("/api/users/me", async Task<IResult> (
}
}).RequireAuthorization();
var socialGroup = app.MapGroup("/api/social")
.RequireAuthorization();
socialGroup.MapGet("/overview", async Task<IResult> (
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var overview = await socialStore.GetOverviewAsync(siteUser.Subject, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(overview);
});
socialGroup.MapGet("/search", async Task<IResult> (
string? query,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
ConnectedUserTracker tracker,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var results = await socialStore.SearchUsersAsync(siteUser.Subject, query, tracker.IsOnline, cancellationToken);
return TypedResults.Ok(results);
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations", async Task<IResult> (
SendFriendInvitationRequest request,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var targetSubject = request.TargetSubject?.Trim() ?? string.Empty;
await socialStore.SendInvitationAsync(siteUser.Subject, targetSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, targetSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/accept", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.AcceptInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapPost("/invitations/{invitationId:long}/decline", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var senderSubject = await socialStore.DeclineInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, senderSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/invitations/{invitationId:long}", async Task<IResult> (
long invitationId,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
try
{
var recipientSubject = await socialStore.CancelInvitationAsync(invitationId, siteUser.Subject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, recipientSubject);
return TypedResults.NoContent();
}
catch (SocialValidationException exception)
{
return TypedResults.BadRequest(new ApiErrorResponse(exception.Message));
}
});
socialGroup.MapDelete("/friends/{friendSubject}", async Task<IResult> (
string friendSubject,
ClaimsPrincipal user,
MySqlSocialStore socialStore,
IHubContext<SocialHub> hubContext,
CancellationToken cancellationToken) =>
{
var siteUser = AuthenticatedSiteUserFactory.FromClaimsPrincipal(user);
if (siteUser is null)
{
return TypedResults.BadRequest(new ApiErrorResponse("La session utilisateur est incomplete."));
}
var normalizedFriendSubject = friendSubject.Trim();
await socialStore.RemoveFriendAsync(siteUser.Subject, normalizedFriendSubject, cancellationToken);
await NotifySocialChangedAsync(hubContext, siteUser.Subject, normalizedFriendSubject);
return TypedResults.NoContent();
});
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminOnly");
@@ -230,11 +401,13 @@ adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
string subject,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
MySqlSocialStore socialStore,
CancellationToken cancellationToken) =>
{
try
{
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
await socialStore.DeleteUserAsync(subject, cancellationToken);
await profileStore.DeleteAsync(subject, cancellationToken);
return TypedResults.NoContent();
}
@@ -307,6 +480,7 @@ app.MapPost("/api/auth/login", async Task<IResult> (
LoginRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
@@ -318,6 +492,7 @@ app.MapPost("/api/auth/login", async Task<IResult> (
{
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
@@ -330,6 +505,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
RegisterRequest request,
HttpContext httpContext,
KeycloakAuthService keycloak,
MySqlUserProfileStore profileStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Username) ||
@@ -355,6 +531,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
}, cancellationToken);
await SignInAsync(httpContext, userInfo);
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
}
catch (KeycloakAuthException exception)
@@ -375,6 +552,9 @@ app.MapGet("/api/auth/logout/browser", async Task<IResult> (HttpContext httpCont
return TypedResults.Redirect("/index.html");
});
app.MapHub<SocialHub>("/hubs/social")
.RequireAuthorization();
app.Run();
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
@@ -577,6 +757,32 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
httpContext.User = principal;
}
static async Task EnsureSiteUserAsync(
MySqlUserProfileStore profileStore,
KeycloakUserInfo userInfo,
CancellationToken cancellationToken)
{
var siteUser = AuthenticatedSiteUserFactory.FromKeycloakUserInfo(userInfo);
if (siteUser is null)
{
return;
}
await profileStore.GetOrCreateAsync(siteUser, cancellationToken);
}
static Task NotifySocialChangedAsync(IHubContext<SocialHub> hubContext, params string[] subjects)
{
var distinctSubjects = subjects
.Where(subject => !string.IsNullOrWhiteSpace(subject))
.Distinct(StringComparer.Ordinal)
.ToArray();
return distinctSubjects.Length == 0
? Task.CompletedTask
: hubContext.Clients.Users(distinctSubjects).SendAsync("SocialChanged");
}
sealed record NormalizedAdminUserUpdate(
string? Email,
string? FirstName,

View File

@@ -0,0 +1,60 @@
namespace ChessCubing.Server.Social;
public sealed class ConnectedUserTracker
{
private readonly object _sync = new();
private readonly Dictionary<string, string> _subjectByConnection = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _connectionsBySubject = new(StringComparer.Ordinal);
public bool TrackConnection(string connectionId, string subject)
{
lock (_sync)
{
_subjectByConnection[connectionId] = subject;
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
connections = new HashSet<string>(StringComparer.Ordinal);
_connectionsBySubject[subject] = connections;
}
var wasOffline = connections.Count == 0;
connections.Add(connectionId);
return wasOffline;
}
}
public (string? Subject, bool BecameOffline) RemoveConnection(string connectionId)
{
lock (_sync)
{
if (!_subjectByConnection.Remove(connectionId, out var subject))
{
return (null, false);
}
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
{
return (subject, false);
}
connections.Remove(connectionId);
if (connections.Count > 0)
{
return (subject, false);
}
_connectionsBySubject.Remove(subject);
return (subject, true);
}
}
public bool IsOnline(string subject)
{
lock (_sync)
{
return _connectionsBySubject.TryGetValue(subject, out var connections)
&& connections.Count > 0;
}
}
}

View File

@@ -0,0 +1,787 @@
using ChessCubing.Server.Data;
using Microsoft.Extensions.Options;
using MySqlConnector;
namespace ChessCubing.Server.Social;
public sealed class MySqlSocialStore(
IOptions<SiteDataOptions> options,
ILogger<MySqlSocialStore> logger)
{
private const string CreateFriendshipsTableSql = """
CREATE TABLE IF NOT EXISTS social_friendships (
id BIGINT NOT NULL AUTO_INCREMENT,
subject_low VARCHAR(190) NOT NULL,
subject_high VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friendships_pair (subject_low, subject_high),
KEY ix_social_friendships_low (subject_low),
KEY ix_social_friendships_high (subject_high)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string CreateInvitationsTableSql = """
CREATE TABLE IF NOT EXISTS social_friend_invitations (
id BIGINT NOT NULL AUTO_INCREMENT,
sender_subject VARCHAR(190) NOT NULL,
recipient_subject VARCHAR(190) NOT NULL,
created_utc DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_social_friend_invitations_pair (sender_subject, recipient_subject),
KEY ix_social_friend_invitations_sender (sender_subject),
KEY ix_social_friend_invitations_recipient (recipient_subject)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
""";
private const string SelectOverviewFriendsSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city
FROM social_friendships f
INNER JOIN site_users u
ON u.subject = CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END
WHERE f.subject_low = @subject OR f.subject_high = @subject
ORDER BY u.display_name ASC, u.username ASC;
""";
private const string SelectReceivedInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.sender_subject
WHERE i.recipient_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SelectSentInvitationsSql = """
SELECT
i.id,
u.subject,
u.username,
u.display_name,
u.email,
i.created_utc
FROM social_friend_invitations i
INNER JOIN site_users u
ON u.subject = i.recipient_subject
WHERE i.sender_subject = @subject
ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC;
""";
private const string SearchUsersTemplateSql = """
SELECT
u.subject,
u.username,
u.display_name,
u.email,
u.club,
u.city,
EXISTS(
SELECT 1
FROM social_friendships f
WHERE (f.subject_low = @subject AND f.subject_high = u.subject)
OR (f.subject_high = @subject AND f.subject_low = u.subject)
) AS is_friend,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
AND i.recipient_subject = u.subject
) AS has_sent_invitation,
EXISTS(
SELECT 1
FROM social_friend_invitations i
WHERE i.sender_subject = u.subject
AND i.recipient_subject = @subject
) AS has_received_invitation
FROM site_users u
WHERE u.subject <> @subject
AND (
u.username LIKE @pattern
OR u.display_name LIKE @pattern
OR COALESCE(u.email, '') LIKE @pattern
OR COALESCE(u.club, '') LIKE @pattern
OR COALESCE(u.city, '') LIKE @pattern
)
ORDER BY
is_friend DESC,
has_received_invitation DESC,
has_sent_invitation DESC,
u.display_name ASC,
u.username ASC
LIMIT @limit;
""";
private const string SelectRelevantPresenceSubjectsSql = """
SELECT participant_subject
FROM (
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS participant_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject
UNION
SELECT i.sender_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.recipient_subject = @subject
UNION
SELECT i.recipient_subject AS participant_subject
FROM social_friend_invitations i
WHERE i.sender_subject = @subject
) participants
WHERE participant_subject <> @subject;
""";
private const string SelectFriendSubjectsSql = """
SELECT CASE
WHEN f.subject_low = @subject THEN f.subject_high
ELSE f.subject_low
END AS friend_subject
FROM social_friendships f
WHERE f.subject_low = @subject OR f.subject_high = @subject;
""";
private const string SelectKnownUserSubjectSql = """
SELECT subject
FROM site_users
WHERE subject = @subject
LIMIT 1;
""";
private const string SelectInvitationBetweenSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA)
LIMIT 1;
""";
private const string SelectInvitationForRecipientSql = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject
LIMIT 1;
""";
private const string SelectFriendshipExistsSql = """
SELECT 1
FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh
LIMIT 1;
""";
private const string InsertInvitationSql = """
INSERT INTO social_friend_invitations (
sender_subject,
recipient_subject,
created_utc
)
VALUES (
@senderSubject,
@recipientSubject,
@createdUtc
);
""";
private const string InsertFriendshipSql = """
INSERT IGNORE INTO social_friendships (
subject_low,
subject_high,
created_utc
)
VALUES (
@subjectLow,
@subjectHigh,
@createdUtc
);
""";
private const string DeleteInvitationByRecipientSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND recipient_subject = @recipientSubject;
""";
private const string DeleteInvitationBySenderSql = """
DELETE FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject;
""";
private const string DeleteInvitationsBetweenSql = """
DELETE FROM social_friend_invitations
WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB)
OR (sender_subject = @subjectB AND recipient_subject = @subjectA);
""";
private const string DeleteFriendshipSql = """
DELETE FROM social_friendships
WHERE subject_low = @subjectLow
AND subject_high = @subjectHigh;
""";
private const string DeleteUserFriendshipsSql = """
DELETE FROM social_friendships
WHERE subject_low = @subject
OR subject_high = @subject;
""";
private const string DeleteUserInvitationsSql = """
DELETE FROM social_friend_invitations
WHERE sender_subject = @subject
OR recipient_subject = @subject;
""";
private readonly SiteDataOptions _options = options.Value;
private readonly ILogger<MySqlSocialStore> _logger = logger;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
{
try
{
await using var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
await CreateSchemaAsync(connection, cancellationToken);
return;
}
catch (Exception exception) when (attempt < _options.InitializationRetries)
{
_logger.LogWarning(
exception,
"Initialisation MySQL impossible pour le module social (tentative {Attempt}/{MaxAttempts}).",
attempt,
_options.InitializationRetries);
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
}
}
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
await finalConnection.OpenAsync(cancellationToken);
await CreateSchemaAsync(finalConnection, cancellationToken);
}
public async Task<SocialOverviewResponse> GetOverviewAsync(
string subject,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var friends = new List<SocialFriendResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectOverviewFriendsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var friendSubject = ReadString(reader, "subject");
friends.Add(new SocialFriendResponse
{
Subject = friendSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(friendSubject),
});
}
}
var receivedInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectReceivedInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var senderSubject = ReadString(reader, "subject");
receivedInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = senderSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(senderSubject),
});
}
}
var sentInvitations = new List<SocialInvitationResponse>();
await using (var command = connection.CreateCommand())
{
command.CommandText = SelectSentInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var recipientSubject = ReadString(reader, "subject");
sentInvitations.Add(new SocialInvitationResponse
{
InvitationId = ReadInt64(reader, "id"),
Subject = recipientSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
CreatedUtc = ReadDateTime(reader, "created_utc"),
IsOnline = isOnline(recipientSubject),
});
}
}
return new SocialOverviewResponse
{
Friends = friends.ToArray(),
ReceivedInvitations = receivedInvitations.ToArray(),
SentInvitations = sentInvitations.ToArray(),
};
}
public async Task<IReadOnlyList<SocialSearchUserResponse>> SearchUsersAsync(
string subject,
string? query,
Func<string, bool> isOnline,
CancellationToken cancellationToken)
{
var normalizedQuery = NormalizeQuery(query);
if (normalizedQuery is null)
{
return [];
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SearchUsersTemplateSql;
command.Parameters.AddWithValue("@subject", subject);
command.Parameters.AddWithValue("@pattern", $"%{normalizedQuery}%");
command.Parameters.AddWithValue("@limit", 12);
var results = new List<SocialSearchUserResponse>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var foundSubject = ReadString(reader, "subject");
results.Add(new SocialSearchUserResponse
{
Subject = foundSubject,
Username = ReadString(reader, "username"),
DisplayName = ReadString(reader, "display_name"),
Email = ReadNullableString(reader, "email"),
Club = ReadNullableString(reader, "club"),
City = ReadNullableString(reader, "city"),
IsOnline = isOnline(foundSubject),
IsFriend = ReadBoolean(reader, "is_friend"),
HasSentInvitation = ReadBoolean(reader, "has_sent_invitation"),
HasReceivedInvitation = ReadBoolean(reader, "has_received_invitation"),
});
}
return results;
}
public async Task SendInvitationAsync(
string senderSubject,
string recipientSubject,
CancellationToken cancellationToken)
{
if (string.Equals(senderSubject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Impossible de t'inviter toi-meme en ami.");
}
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
if (!await UserExistsAsync(connection, transaction, recipientSubject, cancellationToken))
{
throw new SocialValidationException("Le joueur cible est introuvable.");
}
var pair = NormalizePair(senderSubject, recipientSubject);
if (await FriendshipExistsAsync(connection, transaction, pair.SubjectLow, pair.SubjectHigh, cancellationToken))
{
throw new SocialValidationException("Ce joueur fait deja partie de tes amis.");
}
var invitation = await ReadInvitationBetweenAsync(connection, transaction, senderSubject, recipientSubject, cancellationToken);
if (invitation is not null)
{
throw new SocialValidationException(
string.Equals(invitation.SenderSubject, senderSubject, StringComparison.Ordinal)
? "Une invitation est deja en attente pour ce joueur."
: "Ce joueur t'a deja envoye une invitation. Accepte-la depuis la page utilisateur.");
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertInvitationSql;
command.Parameters.AddWithValue("@senderSubject", senderSubject);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
}
public async Task<string> AcceptInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
var pair = NormalizePair(invitation.SenderSubject, invitation.RecipientSubject);
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = InsertFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = DeleteInvitationsBetweenSql;
command.Parameters.AddWithValue("@subjectA", invitation.SenderSubject);
command.Parameters.AddWithValue("@subjectB", invitation.RecipientSubject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await transaction.CommitAsync(cancellationToken);
return invitation.SenderSubject;
}
public async Task<string> DeclineInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForRecipientAsync(connection, transaction: null, invitationId, recipientSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja traitee.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationByRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja traitee.");
}
return invitation.SenderSubject;
}
public async Task<string> CancelInvitationAsync(long invitationId, string senderSubject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
var invitation = await ReadInvitationForSenderAsync(connection, transaction: null, invitationId, senderSubject, cancellationToken)
?? throw new SocialValidationException("Invitation introuvable ou deja retiree.");
await using var command = connection.CreateCommand();
command.CommandText = DeleteInvitationBySenderSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
var deleted = await command.ExecuteNonQueryAsync(cancellationToken);
if (deleted <= 0)
{
throw new SocialValidationException("Invitation introuvable ou deja retiree.");
}
return invitation.RecipientSubject;
}
public async Task RemoveFriendAsync(string subject, string friendSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, friendSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = DeleteFriendshipSql;
command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow);
command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task<bool> AreFriendsAsync(string subject, string otherSubject, CancellationToken cancellationToken)
{
var pair = NormalizePair(subject, otherSubject);
await using var connection = await OpenConnectionAsync(cancellationToken);
return await FriendshipExistsAsync(connection, transaction: null, pair.SubjectLow, pair.SubjectHigh, cancellationToken);
}
public async Task<IReadOnlyList<string>> GetRelevantPresenceSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectRelevantPresenceSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "participant_subject"));
}
return subjects;
}
public async Task<IReadOnlyList<string>> GetFriendSubjectsAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = SelectFriendSubjectsSql;
command.Parameters.AddWithValue("@subject", subject);
var subjects = new List<string>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
subjects.Add(ReadString(reader, "friend_subject"));
}
return subjects;
}
public async Task DeleteUserAsync(string subject, CancellationToken cancellationToken)
{
await using var connection = await OpenConnectionAsync(cancellationToken);
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserFriendshipsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = DeleteUserInvitationsSql;
command.Parameters.AddWithValue("@subject", subject);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken)
{
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateFriendshipsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
await using (var command = connection.CreateCommand())
{
command.CommandText = CreateInvitationsTableSql;
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
private async Task<MySqlConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
var connection = new MySqlConnection(_options.BuildConnectionString());
await connection.OpenAsync(cancellationToken);
return connection;
}
private static (string SubjectLow, string SubjectHigh) NormalizePair(string subjectA, string subjectB)
=> string.CompareOrdinal(subjectA, subjectB) <= 0
? (subjectA, subjectB)
: (subjectB, subjectA);
private static string? NormalizeQuery(string? query)
{
var trimmed = query?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
{
return null;
}
if (trimmed.Length < 2)
{
throw new SocialValidationException("La recherche d'amis demande au moins 2 caracteres.");
}
return trimmed.Length > 80
? trimmed[..80]
: trimmed;
}
private static async Task<bool> UserExistsAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectKnownUserSubjectSql;
command.Parameters.AddWithValue("@subject", subject);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<bool> FriendshipExistsAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
string subjectLow,
string subjectHigh,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectFriendshipExistsSql;
command.Parameters.AddWithValue("@subjectLow", subjectLow);
command.Parameters.AddWithValue("@subjectHigh", subjectHigh);
return await command.ExecuteScalarAsync(cancellationToken) is not null;
}
private static async Task<InvitationRow?> ReadInvitationBetweenAsync(
MySqlConnection connection,
MySqlTransaction transaction,
string subjectA,
string subjectB,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationBetweenSql;
command.Parameters.AddWithValue("@subjectA", subjectA);
command.Parameters.AddWithValue("@subjectB", subjectB);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForRecipientAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string recipientSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = SelectInvitationForRecipientSql;
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@recipientSubject", recipientSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static async Task<InvitationRow?> ReadInvitationForSenderAsync(
MySqlConnection connection,
MySqlTransaction? transaction,
long invitationId,
string senderSubject,
CancellationToken cancellationToken)
{
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
SELECT
id,
sender_subject,
recipient_subject
FROM social_friend_invitations
WHERE id = @invitationId
AND sender_subject = @senderSubject
LIMIT 1;
""";
command.Parameters.AddWithValue("@invitationId", invitationId);
command.Parameters.AddWithValue("@senderSubject", senderSubject);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new InvitationRow(
ReadInt64(reader, "id"),
ReadString(reader, "sender_subject"),
ReadString(reader, "recipient_subject"));
}
private static bool ReadBoolean(MySqlDataReader reader, string columnName)
=> Convert.ToInt32(reader[columnName]) > 0;
private static string ReadString(MySqlDataReader reader, string columnName)
=> reader.GetString(reader.GetOrdinal(columnName));
private static long ReadInt64(MySqlDataReader reader, string columnName)
=> reader.GetInt64(reader.GetOrdinal(columnName));
private static DateTime ReadDateTime(MySqlDataReader reader, string columnName)
=> reader.GetDateTime(reader.GetOrdinal(columnName));
private static string? ReadNullableString(MySqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
return reader.IsDBNull(ordinal)
? null
: reader.GetString(ordinal);
}
private sealed record InvitationRow(long Id, string SenderSubject, string RecipientSubject);
}

View File

@@ -0,0 +1,211 @@
namespace ChessCubing.Server.Social;
public sealed class PlayInviteCoordinator
{
private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2);
private readonly object _sync = new();
private readonly Dictionary<string, PlayInviteState> _invitesById = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _inviteByParticipant = new(StringComparer.Ordinal);
public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor)
{
var normalizedColor = NormalizeRecipientColor(recipientColor);
lock (_sync)
{
CleanupExpiredUnsafe();
if (_inviteByParticipant.ContainsKey(sender.Subject))
{
throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte.");
}
if (_inviteByParticipant.ContainsKey(recipient.Subject))
{
throw new SocialValidationException("Cet ami traite deja une autre invitation de partie.");
}
var nowUtc = DateTime.UtcNow;
var state = new PlayInviteState(
Guid.NewGuid().ToString("N"),
sender,
recipient,
normalizedColor,
nowUtc,
nowUtc.Add(InviteLifetime));
_invitesById[state.InviteId] = state;
_inviteByParticipant[sender.Subject] = state.InviteId;
_inviteByParticipant[recipient.Subject] = state.InviteId;
return MapInvite(state);
}
}
public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "cancelled",
Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.",
});
}
}
public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut refuser cette invitation.");
}
RemoveInviteUnsafe(state);
return new PlayInviteCloseResult(
state.Sender.Subject,
state.Recipient.Subject,
new PlayInviteClosedMessage
{
InviteId = state.InviteId,
Reason = "declined",
Message = $"{state.Recipient.DisplayName} a refuse la partie.",
});
}
}
public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject)
{
lock (_sync)
{
var state = GetActiveInviteUnsafe(inviteId);
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
{
throw new SocialValidationException("Seul le destinataire peut accepter cette invitation.");
}
RemoveInviteUnsafe(state);
var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Recipient
: state.Sender;
var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
? state.Sender
: state.Recipient;
var session = new PlaySessionResponse
{
SessionId = Guid.NewGuid().ToString("N"),
WhiteSubject = white.Subject,
WhiteName = white.DisplayName,
BlackSubject = black.Subject,
BlackName = black.DisplayName,
InitiatorSubject = state.Sender.Subject,
RecipientSubject = state.Recipient.Subject,
ConfirmedUtc = DateTime.UtcNow,
};
return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session);
}
}
private static string NormalizeRecipientColor(string recipientColor)
{
var normalized = recipientColor.Trim().ToLowerInvariant();
return normalized switch
{
"white" => "white",
"black" => "black",
_ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."),
};
}
private static PlayInviteMessage MapInvite(PlayInviteState state)
=> new()
{
InviteId = state.InviteId,
SenderSubject = state.Sender.Subject,
SenderUsername = state.Sender.Username,
SenderDisplayName = state.Sender.DisplayName,
RecipientSubject = state.Recipient.Subject,
RecipientUsername = state.Recipient.Username,
RecipientDisplayName = state.Recipient.DisplayName,
RecipientColor = state.RecipientColor,
CreatedUtc = state.CreatedUtc,
ExpiresUtc = state.ExpiresUtc,
};
private PlayInviteState GetActiveInviteUnsafe(string inviteId)
{
CleanupExpiredUnsafe();
if (!_invitesById.TryGetValue(inviteId, out var state))
{
throw new SocialValidationException("Cette invitation de partie n'est plus disponible.");
}
return state;
}
private void CleanupExpiredUnsafe()
{
var nowUtc = DateTime.UtcNow;
var expiredInviteIds = _invitesById.Values
.Where(state => state.ExpiresUtc <= nowUtc)
.Select(state => state.InviteId)
.ToArray();
foreach (var expiredInviteId in expiredInviteIds)
{
if (_invitesById.TryGetValue(expiredInviteId, out var state))
{
RemoveInviteUnsafe(state);
}
}
}
private void RemoveInviteUnsafe(PlayInviteState state)
{
_invitesById.Remove(state.InviteId);
_inviteByParticipant.Remove(state.Sender.Subject);
_inviteByParticipant.Remove(state.Recipient.Subject);
}
private sealed record PlayInviteState(
string InviteId,
PlayInviteParticipant Sender,
PlayInviteParticipant Recipient,
string RecipientColor,
DateTime CreatedUtc,
DateTime ExpiresUtc);
}
public readonly record struct PlayInviteParticipant(
string Subject,
string Username,
string DisplayName);
public sealed record PlayInviteCloseResult(
string SenderSubject,
string RecipientSubject,
PlayInviteClosedMessage ClosedMessage);
public sealed record PlayInviteAcceptResult(
string SenderSubject,
string RecipientSubject,
PlaySessionResponse Session);

View File

@@ -0,0 +1,137 @@
namespace ChessCubing.Server.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; }
}
public sealed class SocialValidationException(string message) : Exception(message);

View File

@@ -0,0 +1,168 @@
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<PlayInviteMessage> 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<string> 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,
});
}
}

View File

@@ -1,3 +1,4 @@
using ChessCubing.Server.Auth;
using System.Security.Claims;
namespace ChessCubing.Server.Users;
@@ -10,6 +11,28 @@ public sealed record AuthenticatedSiteUser(
public static class AuthenticatedSiteUserFactory
{
public static AuthenticatedSiteUser? FromKeycloakUserInfo(KeycloakUserInfo userInfo)
{
if (string.IsNullOrWhiteSpace(userInfo.Subject))
{
return null;
}
var username = string.IsNullOrWhiteSpace(userInfo.PreferredUsername)
? userInfo.Subject
: userInfo.PreferredUsername;
var displayName = string.IsNullOrWhiteSpace(userInfo.Name)
? username
: userInfo.Name;
return new AuthenticatedSiteUser(
userInfo.Subject.Trim(),
username.Trim(),
string.IsNullOrWhiteSpace(userInfo.Email) ? null : userInfo.Email.Trim(),
displayName.Trim());
}
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)

View File

@@ -41,6 +41,18 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /hubs/ {
proxy_http_version 1.1;
proxy_pass http://auth:8080/hubs/;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -3571,3 +3571,205 @@ body[data-page="chrono"] .player-clock.negative-clock {
font-size: clamp(1.05rem, 5.8vw, 1.7rem);
}
}
.user-social-panel {
display: grid;
gap: 1.2rem;
}
.social-search-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: end;
}
.social-search-actions {
display: flex;
align-items: flex-end;
}
.social-search-results,
.social-list {
display: grid;
gap: 0.75rem;
}
.social-columns {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.social-card,
.setup-social-card {
display: grid;
gap: 0.9rem;
padding: 1rem 1.05rem;
border: 1px solid var(--panel-border);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)),
rgba(10, 12, 20, 0.82);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
.social-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.social-card-head strong {
display: block;
font-size: 1.05rem;
}
.social-empty {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.social-item,
.play-friend-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
}
.social-item-meta {
display: grid;
gap: 0.22rem;
min-width: 0;
}
.social-item-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.social-item-title-row strong {
font-size: 1rem;
}
.social-item-subtitle,
.social-item-caption {
color: var(--muted);
font-size: 0.94rem;
}
.social-item-actions,
.play-friend-actions,
.play-overlay-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.presence-badge {
display: inline-flex;
align-items: center;
padding: 0.22rem 0.58rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.presence-badge.online {
border-color: rgba(93, 219, 126, 0.32);
background: rgba(93, 219, 126, 0.12);
color: #baf3c6;
}
.social-inline-feedback,
.play-invite-pending {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 0.85rem 0.95rem;
border: 1px solid rgba(255, 177, 58, 0.22);
border-radius: 18px;
background: rgba(255, 177, 58, 0.08);
}
.social-inline-feedback span,
.play-invite-pending p {
margin: 0;
}
.play-invite-pending strong {
display: block;
margin-top: 0.15rem;
}
.play-friends-list {
display: grid;
gap: 0.75rem;
}
.play-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 1rem;
}
.play-overlay-card {
position: relative;
z-index: 1;
width: min(100%, 32rem);
}
.play-overlay-copy {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.6;
}
@media (max-width: 1100px) {
.social-columns {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.social-search-form {
grid-template-columns: 1fr;
}
.social-search-actions {
justify-content: flex-start;
}
.social-item,
.play-friend-item,
.social-inline-feedback,
.play-invite-pending {
flex-direction: column;
align-items: stretch;
}
.social-item-actions,
.play-friend-actions,
.play-overlay-actions {
width: 100%;
}
}