Ajoute les amis et les invitations temps reel
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.2" />
|
<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.Authentication" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.2" PrivateAssets="all" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
61
ChessCubing.App/Components/PlayInviteOverlay.razor
Normal file
61
ChessCubing.App/Components/PlayInviteOverlay.razor
Normal 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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ else
|
|||||||
@Body
|
@Body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<PlayInviteOverlay />
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool HideGlobalMenu
|
private bool HideGlobalMenu
|
||||||
{
|
{
|
||||||
|
|||||||
135
ChessCubing.App/Models/Social/SocialModels.cs
Normal file
135
ChessCubing.App/Models/Social/SocialModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
|
@inject SocialRealtimeService Realtime
|
||||||
|
|
||||||
<PageTitle>ChessCubing Arena | Application</PageTitle>
|
<PageTitle>ChessCubing Arena | Application</PageTitle>
|
||||||
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
|
<PageBody Page="setup" BodyClass="@SetupBodyClass" />
|
||||||
@@ -180,6 +181,93 @@
|
|||||||
<input @bind="Form.BlackName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
|
<input @bind="Form.BlackName" @bind:event="oninput" name="blackName" type="text" maxlength="40" placeholder="Noir" />
|
||||||
</label>
|
</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)
|
@if (Form.CompetitionMode)
|
||||||
{
|
{
|
||||||
<label class="field" id="arbiterField">
|
<label class="field" id="arbiterField">
|
||||||
@@ -287,12 +375,24 @@
|
|||||||
@code {
|
@code {
|
||||||
private SetupFormModel Form { get; set; } = new();
|
private SetupFormModel Form { get; set; } = new();
|
||||||
private bool _ready;
|
private bool _ready;
|
||||||
|
private bool IsAuthenticated;
|
||||||
|
private bool IsSocialLoading;
|
||||||
|
private int _knownSocialVersion;
|
||||||
private string? ConnectedPlayerName;
|
private string? ConnectedPlayerName;
|
||||||
|
private string? SocialLoadError;
|
||||||
|
private string? InviteActionError;
|
||||||
|
private SocialOverviewResponse? SocialOverview;
|
||||||
|
|
||||||
private MatchState? CurrentMatch => Store.Current;
|
private MatchState? CurrentMatch => Store.Current;
|
||||||
|
|
||||||
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
private string SetupBodyClass => UsesMoveLimit ? string.Empty : "time-setup-mode";
|
||||||
private bool CanUseConnectedPlayerName => !string.IsNullOrWhiteSpace(ConnectedPlayerName);
|
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);
|
private bool UsesMoveLimit => MatchEngine.UsesMoveLimit(Form.Mode);
|
||||||
|
|
||||||
@@ -324,7 +424,9 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
await LoadConnectedPlayerAsync();
|
Realtime.Changed += HandleRealtimeChanged;
|
||||||
|
await Realtime.EnsureStartedAsync();
|
||||||
|
await LoadApplicationContextAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
@@ -340,9 +442,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
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;
|
string? fallbackName = null;
|
||||||
|
|
||||||
@@ -353,19 +472,30 @@
|
|||||||
|
|
||||||
if (user.Identity?.IsAuthenticated != true)
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
|
IsAuthenticated = false;
|
||||||
ConnectedPlayerName = null;
|
ConnectedPlayerName = null;
|
||||||
|
ResetSocialState();
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IsAuthenticated = true;
|
||||||
fallbackName = BuildConnectedPlayerFallback(user);
|
fallbackName = BuildConnectedPlayerFallback(user);
|
||||||
|
|
||||||
var response = await Http.GetAsync("api/users/me");
|
var response = await Http.GetAsync("api/users/me");
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
ConnectedPlayerName = response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden
|
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||||
? null
|
{
|
||||||
: fallbackName;
|
IsAuthenticated = false;
|
||||||
|
ConnectedPlayerName = null;
|
||||||
|
ResetSocialState();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectedPlayerName = fallbackName;
|
||||||
|
await LoadSocialOverviewAsync();
|
||||||
|
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
return;
|
return;
|
||||||
@@ -375,15 +505,62 @@
|
|||||||
ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName)
|
ConnectedPlayerName = !string.IsNullOrWhiteSpace(profile?.DisplayName)
|
||||||
? profile.DisplayName
|
? profile.DisplayName
|
||||||
: fallbackName;
|
: fallbackName;
|
||||||
|
await LoadSocialOverviewAsync();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
ConnectedPlayerName = fallbackName;
|
ConnectedPlayerName = fallbackName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApplyAcceptedPlaySession();
|
||||||
await InvokeAsync(StateHasChanged);
|
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()
|
private async Task HandleSubmit()
|
||||||
{
|
{
|
||||||
await Store.EnsureLoadedAsync();
|
await Store.EnsureLoadedAsync();
|
||||||
@@ -426,6 +603,48 @@
|
|||||||
Form.BlackName = ConnectedPlayerName!;
|
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)
|
private void SetMode(string mode)
|
||||||
=> Form.Mode = mode;
|
=> Form.Mode = mode;
|
||||||
|
|
||||||
@@ -470,6 +689,44 @@
|
|||||||
private string BuildPrefillTitle(string color)
|
private string BuildPrefillTitle(string color)
|
||||||
=> $"Utiliser mon nom cote {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)
|
private static string? BuildConnectedPlayerFallback(ClaimsPrincipal user)
|
||||||
=> FirstNonEmpty(
|
=> FirstNonEmpty(
|
||||||
user.FindFirst("name")?.Value,
|
user.FindFirst("name")?.Value,
|
||||||
@@ -481,5 +738,8 @@
|
|||||||
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
=> candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate));
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
{
|
||||||
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
|
@inject SocialRealtimeService Realtime
|
||||||
|
|
||||||
<PageTitle>ChessCubing Arena | Utilisateur</PageTitle>
|
<PageTitle>ChessCubing Arena | Utilisateur</PageTitle>
|
||||||
<PageBody BodyClass="home-body" />
|
<PageBody BodyClass="home-body" />
|
||||||
@@ -18,12 +19,11 @@
|
|||||||
<header class="hero hero-home user-hero">
|
<header class="hero hero-home user-hero">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Espace utilisateur</p>
|
<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">
|
<p class="lead">
|
||||||
Cette page relie le compte connecte a une fiche utilisateur stockee
|
Cette page regroupe le profil du site, la gestion des amis ChessCubing,
|
||||||
cote serveur en MySQL. L'authentification reste geree par Keycloak,
|
ainsi que les invitations recues ou envoyees. Les confirmations de partie
|
||||||
mais les informations metier du site sont maintenant pretes pour des
|
sont ensuite synchronisees en direct avec SignalR.
|
||||||
evolutions futures.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button secondary" href="application.html">Ouvrir l'application</a>
|
<a class="button secondary" href="application.html">Ouvrir l'application</a>
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
|
|
||||||
<aside class="hero-preview">
|
<aside class="hero-preview">
|
||||||
<div class="preview-card">
|
<div class="preview-card">
|
||||||
<p class="micro-label">Persistance</p>
|
<p class="micro-label">Temps reel</p>
|
||||||
<strong>Profil du site stocke en MySQL</strong>
|
<strong>Presence et invitations synchronisees</strong>
|
||||||
<p>
|
<p>
|
||||||
Username et email restent lies au compte authentifie, pendant que
|
Les amis connectes apparaissent en direct, et les invitations de
|
||||||
le profil ChessCubing ajoute les donnees utiles au site.
|
partie peuvent etre confirmees depuis un autre device.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-banner">
|
<div class="preview-banner">
|
||||||
@@ -55,11 +55,12 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Connexion requise</p>
|
<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>
|
</div>
|
||||||
<p class="section-copy">
|
<p class="section-copy">
|
||||||
Utilise les boutons Se connecter ou Creer un compte dans le menu
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,15 +87,15 @@
|
|||||||
{
|
{
|
||||||
<section class="panel panel-wide">
|
<section class="panel panel-wide">
|
||||||
<p class="eyebrow">Chargement</p>
|
<p class="eyebrow">Chargement</p>
|
||||||
<h2>Recuperation du profil utilisateur</h2>
|
<h2>Recuperation de l'espace utilisateur</h2>
|
||||||
<p class="section-copy">Le serveur recharge les donnees enregistrees pour ce compte.</p>
|
<p class="section-copy">Le serveur recharge le profil, les amis et les invitations de ce compte.</p>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(LoadError))
|
else if (!string.IsNullOrWhiteSpace(LoadError))
|
||||||
{
|
{
|
||||||
<section class="panel panel-wide">
|
<section class="panel panel-wide">
|
||||||
<p class="eyebrow">Serveur</p>
|
<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>
|
<p class="profile-feedback error">@LoadError</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
|
<button class="button secondary" type="button" @onclick="LoadProfileAsync">Reessayer</button>
|
||||||
@@ -217,6 +218,235 @@
|
|||||||
</div>
|
</div>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,12 +455,22 @@
|
|||||||
private readonly UserProfileFormModel Form = new();
|
private readonly UserProfileFormModel Form = new();
|
||||||
|
|
||||||
private UserProfileResponse? Profile;
|
private UserProfileResponse? Profile;
|
||||||
|
private SocialOverviewResponse? SocialOverview;
|
||||||
|
private SocialSearchUserResponse[] SearchResults = [];
|
||||||
private bool IsAuthenticated;
|
private bool IsAuthenticated;
|
||||||
private bool IsLoading = true;
|
private bool IsLoading = true;
|
||||||
private bool IsSaving;
|
private bool IsSaving;
|
||||||
|
private bool IsSocialLoading;
|
||||||
|
private bool IsSearching;
|
||||||
|
private int _knownSocialVersion;
|
||||||
private string? LoadError;
|
private string? LoadError;
|
||||||
private string? SaveError;
|
private string? SaveError;
|
||||||
private string? SaveMessage;
|
private string? SaveMessage;
|
||||||
|
private string? SocialLoadError;
|
||||||
|
private string? SocialActionError;
|
||||||
|
private string? SocialActionMessage;
|
||||||
|
private string? SearchError;
|
||||||
|
private string SearchQuery = string.Empty;
|
||||||
|
|
||||||
private string HeroStatusTitle
|
private string HeroStatusTitle
|
||||||
=> !IsAuthenticated
|
=> !IsAuthenticated
|
||||||
@@ -241,20 +481,55 @@
|
|||||||
|
|
||||||
private string HeroStatusDescription
|
private string HeroStatusDescription
|
||||||
=> !IsAuthenticated
|
=> !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
|
: IsLoading
|
||||||
? "Le serveur verifie la fiche utilisateur associee a ce compte."
|
? "Le serveur verifie la fiche utilisateur et les relations sociales associees a ce compte."
|
||||||
: $"Compte lie a {Profile?.Username ?? "l'utilisateur connecte"} et stocke en base MySQL.";
|
: $"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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
AuthenticationStateProvider.AuthenticationStateChanged += HandleAuthenticationStateChanged;
|
||||||
|
Realtime.Changed += HandleRealtimeChanged;
|
||||||
|
await Realtime.EnsureStartedAsync();
|
||||||
await LoadProfileAsync();
|
await LoadProfileAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
private void HandleAuthenticationStateChanged(Task<AuthenticationState> authenticationStateTask)
|
||||||
=> _ = InvokeAsync(LoadProfileAsync);
|
=> _ = 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()
|
private async Task LoadProfileAsync()
|
||||||
{
|
{
|
||||||
LoadError = null;
|
LoadError = null;
|
||||||
@@ -268,6 +543,7 @@
|
|||||||
if (authState.User.Identity?.IsAuthenticated != true)
|
if (authState.User.Identity?.IsAuthenticated != true)
|
||||||
{
|
{
|
||||||
ResetProfileState();
|
ResetProfileState();
|
||||||
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +553,7 @@
|
|||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
ResetProfileState();
|
ResetProfileState();
|
||||||
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +561,7 @@
|
|||||||
{
|
{
|
||||||
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
LoadError = await ReadErrorAsync(response, "Le profil utilisateur n'a pas pu etre charge.");
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,20 +569,24 @@
|
|||||||
if (Profile is null)
|
if (Profile is null)
|
||||||
{
|
{
|
||||||
LoadError = "Le serveur a retourne une reponse vide.";
|
LoadError = "Le serveur a retourne une reponse vide.";
|
||||||
|
ResetSocialState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
FillForm(Profile);
|
FillForm(Profile);
|
||||||
|
await LoadSocialOverviewAsync();
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
LoadError = "Le service utilisateur est temporairement indisponible.";
|
LoadError = "Le service utilisateur est temporairement indisponible.";
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetSocialState();
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
LoadError = "La reponse du service utilisateur a pris trop de temps.";
|
||||||
Profile = null;
|
Profile = null;
|
||||||
|
ResetSocialState();
|
||||||
}
|
}
|
||||||
finally
|
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()
|
private async Task SaveProfileAsync()
|
||||||
{
|
{
|
||||||
if (IsSaving || !IsAuthenticated)
|
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()
|
private void ResetProfileState()
|
||||||
{
|
{
|
||||||
IsAuthenticated = false;
|
IsAuthenticated = false;
|
||||||
@@ -374,6 +852,18 @@
|
|||||||
Form.Reset();
|
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)
|
private void FillForm(UserProfileResponse profile)
|
||||||
{
|
{
|
||||||
Form.DisplayName = profile.DisplayName;
|
Form.DisplayName = profile.DisplayName;
|
||||||
@@ -384,6 +874,28 @@
|
|||||||
Form.Bio = profile.Bio;
|
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)
|
private static async Task<string> ReadErrorAsync(HttpResponseMessage response, string fallbackMessage)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -409,7 +921,10 @@
|
|||||||
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
=> value.ToLocalTime().ToString("dd MMM yyyy 'a' HH:mm", CultureInfo.GetCultureInfo("fr-FR"));
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
=> AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
{
|
||||||
|
AuthenticationStateProvider.AuthenticationStateChanged -= HandleAuthenticationStateChanged;
|
||||||
|
Realtime.Changed -= HandleRealtimeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ApiErrorMessage
|
private sealed class ApiErrorMessage
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredServ
|
|||||||
builder.Services.AddScoped<BrowserBridge>();
|
builder.Services.AddScoped<BrowserBridge>();
|
||||||
builder.Services.AddScoped<UserSession>();
|
builder.Services.AddScoped<UserSession>();
|
||||||
builder.Services.AddScoped<MatchStore>();
|
builder.Services.AddScoped<MatchStore>();
|
||||||
|
builder.Services.AddScoped<SocialRealtimeService>();
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
329
ChessCubing.App/Services/SocialRealtimeService.cs
Normal file
329
ChessCubing.App/Services/SocialRealtimeService.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@using ChessCubing.App
|
@using ChessCubing.App
|
||||||
@using ChessCubing.App.Components
|
@using ChessCubing.App.Components
|
||||||
@using ChessCubing.App.Models
|
@using ChessCubing.App.Models
|
||||||
|
@using ChessCubing.App.Models.Social
|
||||||
@using ChessCubing.App.Services
|
@using ChessCubing.App.Services
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ using System.Net.Mail;
|
|||||||
using ChessCubing.Server.Admin;
|
using ChessCubing.Server.Admin;
|
||||||
using ChessCubing.Server.Auth;
|
using ChessCubing.Server.Auth;
|
||||||
using ChessCubing.Server.Data;
|
using ChessCubing.Server.Data;
|
||||||
|
using ChessCubing.Server.Social;
|
||||||
using ChessCubing.Server.Users;
|
using ChessCubing.Server.Users;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -67,15 +69,21 @@ builder.Services.AddAuthorization(options =>
|
|||||||
{
|
{
|
||||||
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
|
options.AddPolicy("AdminOnly", policy => policy.RequireRole("admin"));
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddHttpClient<KeycloakAuthService>();
|
builder.Services.AddHttpClient<KeycloakAuthService>();
|
||||||
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
builder.Services.AddSingleton<MySqlUserProfileStore>();
|
||||||
|
builder.Services.AddSingleton<MySqlSocialStore>();
|
||||||
|
builder.Services.AddSingleton<ConnectedUserTracker>();
|
||||||
|
builder.Services.AddSingleton<PlayInviteCoordinator>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
var profileStore = scope.ServiceProvider.GetRequiredService<MySqlUserProfileStore>();
|
||||||
|
var socialStore = scope.ServiceProvider.GetRequiredService<MySqlSocialStore>();
|
||||||
await profileStore.InitializeAsync(CancellationToken.None);
|
await profileStore.InitializeAsync(CancellationToken.None);
|
||||||
|
await socialStore.InitializeAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
@@ -124,6 +132,169 @@ app.MapPut("/api/users/me", async Task<IResult> (
|
|||||||
}
|
}
|
||||||
}).RequireAuthorization();
|
}).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")
|
var adminGroup = app.MapGroup("/api/admin")
|
||||||
.RequireAuthorization("AdminOnly");
|
.RequireAuthorization("AdminOnly");
|
||||||
|
|
||||||
@@ -230,11 +401,13 @@ adminGroup.MapDelete("/users/{subject}", async Task<IResult> (
|
|||||||
string subject,
|
string subject,
|
||||||
KeycloakAuthService keycloak,
|
KeycloakAuthService keycloak,
|
||||||
MySqlUserProfileStore profileStore,
|
MySqlUserProfileStore profileStore,
|
||||||
|
MySqlSocialStore socialStore,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
|
await keycloak.DeleteAdminUserAsync(subject, cancellationToken);
|
||||||
|
await socialStore.DeleteUserAsync(subject, cancellationToken);
|
||||||
await profileStore.DeleteAsync(subject, cancellationToken);
|
await profileStore.DeleteAsync(subject, cancellationToken);
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
}
|
}
|
||||||
@@ -307,6 +480,7 @@ app.MapPost("/api/auth/login", async Task<IResult> (
|
|||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
KeycloakAuthService keycloak,
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
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);
|
var userInfo = await keycloak.LoginAsync(request.Username.Trim(), request.Password, cancellationToken);
|
||||||
await SignInAsync(httpContext, userInfo);
|
await SignInAsync(httpContext, userInfo);
|
||||||
|
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
|
||||||
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
||||||
}
|
}
|
||||||
catch (KeycloakAuthException exception)
|
catch (KeycloakAuthException exception)
|
||||||
@@ -330,6 +505,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
|
|||||||
RegisterRequest request,
|
RegisterRequest request,
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
KeycloakAuthService keycloak,
|
KeycloakAuthService keycloak,
|
||||||
|
MySqlUserProfileStore profileStore,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) ||
|
if (string.IsNullOrWhiteSpace(request.Username) ||
|
||||||
@@ -355,6 +531,7 @@ app.MapPost("/api/auth/register", async Task<IResult> (
|
|||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
await SignInAsync(httpContext, userInfo);
|
await SignInAsync(httpContext, userInfo);
|
||||||
|
await EnsureSiteUserAsync(profileStore, userInfo, cancellationToken);
|
||||||
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
return TypedResults.Ok(AuthSessionResponse.FromUser(httpContext.User));
|
||||||
}
|
}
|
||||||
catch (KeycloakAuthException exception)
|
catch (KeycloakAuthException exception)
|
||||||
@@ -375,6 +552,9 @@ app.MapGet("/api/auth/logout/browser", async Task<IResult> (HttpContext httpCont
|
|||||||
return TypedResults.Redirect("/index.html");
|
return TypedResults.Redirect("/index.html");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapHub<SocialHub>("/hubs/social")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
|
static AdminUserSummaryResponse MapAdminSummary(AdminIdentityUser identity, UserProfileResponse? profile)
|
||||||
@@ -577,6 +757,32 @@ static async Task SignInAsync(HttpContext httpContext, KeycloakUserInfo userInfo
|
|||||||
httpContext.User = principal;
|
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(
|
sealed record NormalizedAdminUserUpdate(
|
||||||
string? Email,
|
string? Email,
|
||||||
string? FirstName,
|
string? FirstName,
|
||||||
|
|||||||
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal file
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal file
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal 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);
|
||||||
|
}
|
||||||
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal file
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal 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);
|
||||||
137
ChessCubing.Server/Social/SocialContracts.cs
Normal file
137
ChessCubing.Server/Social/SocialContracts.cs
Normal 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);
|
||||||
168
ChessCubing.Server/Social/SocialHub.cs
Normal file
168
ChessCubing.Server/Social/SocialHub.cs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ChessCubing.Server.Auth;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace ChessCubing.Server.Users;
|
namespace ChessCubing.Server.Users;
|
||||||
@@ -10,6 +11,28 @@ public sealed record AuthenticatedSiteUser(
|
|||||||
|
|
||||||
public static class AuthenticatedSiteUserFactory
|
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)
|
public static AuthenticatedSiteUser? FromClaimsPrincipal(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
if (user.Identity?.IsAuthenticated != true)
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
|||||||
12
nginx.conf
12
nginx.conf
@@ -41,6 +41,18 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
202
styles.css
202
styles.css
@@ -3571,3 +3571,205 @@ body[data-page="chrono"] .player-clock.negative-clock {
|
|||||||
font-size: clamp(1.05rem, 5.8vw, 1.7rem);
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user