SignalR chat connection OK

This commit is contained in:
Paul Schneider 2026-10-03 22:47:47 +01:00
commit b22a26f61e
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
43 changed files with 430 additions and 252 deletions

View file

@ -4,6 +4,7 @@
<application
android:label="PostIt"
android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config">
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true">
</application>
</manifest>

View file

@ -1,4 +1,5 @@
using System.Net.Http;
using PostIt.Models;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;

View file

@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
@ -21,7 +22,9 @@ public class StatusBarTests
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar { DataContext = StatusNotice.Error("Échec de la suppression : boom") };
var bar = new StatusBar {
DataContext =
StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
@ -32,4 +35,4 @@ public class StatusBarTests
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}
}

View file

@ -10,16 +10,18 @@
Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}"
FontWeight="Bold"
Foreground="{Binding Foreground}"
VerticalAlignment="Top"/>
<!-- SelectableTextBlock so the user can select + copy the
status message (TextBlock is not selectable in
Avalonia). Keeps Text/TextWrapping/Foreground. -->
FontWeight="Bold"
Foreground="{Binding Foreground}"
VerticalAlignment="Top"/>
<SelectableTextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
Foreground="{Binding Foreground}"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
<Button Content="{Binding CheckCommandLabel}"
VerticalAlignment="Center"
IsVisible="{Binding HasCheckCommand}"
Command="{Binding CheckCommand}"
/>
</StackPanel>
</Border>
</UserControl>

View file

@ -0,0 +1,14 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO minimal calqué sur l'objet anonyme émis par le hub
/// (<c>{ Name, Room, Message }</c>) via l'événement
/// <c>ReceiveMessage</c>.
/// </summary>
public class ChatMessageDto
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace PostIt.ViewModels.Chat
{
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
public class ChatRoomInfo
{
public string? Name { get; set; }
public List<string> Users { get; set; } = new();
}
}

View file

@ -0,0 +1,6 @@
namespace PostIt.ViewModels;
internal interface IWithLabel
{
string Label { get; }
}

View file

@ -1,35 +0,0 @@
namespace PostIt.ViewModels;
public enum StatusSeverity
{
Info,
Warning,
Error
}
public sealed class StatusNotice
{
public string Message { get; }
public StatusSeverity Severity { get; }
public string Glyph { get; }
public string Background { get; }
public string BorderBrush { get; }
public string Foreground { get; }
public StatusNotice(string message, StatusSeverity severity)
{
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity;
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
}
public static StatusNotice Info(string message) => new(message, StatusSeverity.Info);
public static StatusNotice Warning(string message) => new(message, StatusSeverity.Warning);
public static StatusNotice Error(string message) => new(message, StatusSeverity.Error);
}

View file

@ -0,0 +1,8 @@
namespace PostIt.Models;
public enum StatusSeverity
{
Info,
Warning,
Error
}

View file

@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Models;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;

View file

@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
using PostIt.Models;
namespace PostIt.ViewModels;

View file

@ -8,6 +8,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;

View file

@ -12,6 +12,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow;
using PostIt.Models;
namespace PostIt.ViewModels;

View file

@ -388,4 +388,4 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
QueryStatus.Success => "V",
_ => "i",
};
}
}

View file

@ -6,6 +6,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;

View file

@ -10,6 +10,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;

View file

@ -9,6 +9,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;

View file

@ -13,6 +13,7 @@ using PostIt.Helpers;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using PostIt.Models;
namespace PostIt.ViewModels;

View file

@ -12,6 +12,7 @@ using Yavsc.Api.Client;
using Yavsc.Abstract.Files;
using PostIt.Helpers;
using AvaloniaEdit.Document;
using PostIt.Models;
namespace PostIt.ViewModels;

View file

@ -4,18 +4,37 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
using Yavsc;
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// ViewModel pour la gestion du chat,
/// incluant la connexion au Hub SignalR,
/// la gestion des salons et des utilisateurs connectés.
/// </summary>
public partial class ChatViewModel : ViewModelBase
{
private readonly Settings _settings;
private readonly YavscApiClient? _api;
// Le paramètre optionnel permet l'instanciation design-time
// (Design.DataContext dans ChatPage.axaml) sans conteneur DI.
public ChatViewModel(YavscApiClient? api = null)
{
var app = App.Current as App;
_settings = app!.ServiceProvider!.GetRequiredService<Settings>(); // Assurez-vous que _settings est initialisé avant utilisation.
_api = api;
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
private HubConnection? _connection;
[ObservableProperty]
public partial string ServerUrl { get; protected set; } = "https://yavsc.pschneider.fr/chathub";
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@ -24,7 +43,6 @@ namespace PostIt.ViewModels.Chat
[ObservableProperty]
public partial StatusNotice Status { get; set; }
= new StatusNotice("Déconnecté", StatusSeverity.Info);
[ObservableProperty]
public partial bool IsConnected { get; set; } = false;
@ -40,20 +58,61 @@ namespace PostIt.ViewModels.Chat
[RelayCommand]
public async Task JoinRoomCommand() => await JoinRoomAsync();
[RelayCommand]
public async Task ChangeNickCommand() => await ChangeNickAsync();
public override bool CanNavigateNext { get; protected set; } = false;
public override bool CanNavigatePrevious { get; protected set; } = true;
private async Task DisconnectAsync()
{
if (_connection != null)
{
await _connection.StopAsync();
await _connection.DisposeAsync();
_connection = null;
}
IsConnected = false;
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
private async Task ConnectAsync()
{
try
{
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info);
Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info,
new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync()));
if (_api == null || !_api.HasValidSession)
{
Status = new StatusNotice("Session expirée : reconnectez-vous avant d'ouvrir le chat.", StatusSeverity.Error,
new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
return;
}
// Le hub est mappé à la racine du service ("/chathub"),
// pas sous le préfixe versionné "/api/v1" : on le retire.
var hubBase = _settings.ApiUrl.TrimEnd('/');
if (hubBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
hubBase = hubBase[..^"/api/v1".Length];
var hubUrl = hubBase + "/" + Constants.ChatHubPath;
_connection = new HubConnectionBuilder()
.WithUrl(ServerUrl)
.WithUrl(hubUrl,
options =>
{
// Le hub est [Authorize] : on attache le Bearer OIDC.
// AccessTokenProvider est rappelé à chaque reconnexion,
// donc on tente un refresh silencieux à chaque appel.
// Un refresh mort (invalid_grant) ne doit pas faire
// échouer StartAsync avec une exception interne : on
// renvoie le token courant (éventuellement expiré) et
// le serveur répondra un 401 propre, surfacé à l'UI.
options.AccessTokenProvider = async () =>
{
await _api.TrySilentLoginAsync();
return _api.CurrentAccessToken;
};
})
.WithAutomaticReconnect()
.Build();
@ -88,15 +147,20 @@ namespace PostIt.ViewModels.Chat
await _connection.StartAsync();
IsConnected = true;
Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info);
Status = new StatusNotice("Connecté au Hub SignalR", StatusSeverity.Info, new RelayCheckCommand("Déconnecter", async () => await DisconnectAsync()));
}
catch (Exception ex)
{
Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error);
Status = new StatusNotice($"Erreur : {ex.Message}", StatusSeverity.Error, new RelayCheckCommand("Reconnecter", async () => await ConnectAsync()));
IsConnected = false;
}
}
private async Task CheckConnectionAsync()
{
throw new NotImplementedException();
}
private async Task JoinRoomAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
@ -115,21 +179,5 @@ namespace PostIt.ViewModels.Chat
Messages.Add($"Erreur Join: {ex.Message}");
}
}
private async Task ChangeNickAsync()
{
if (_connection == null || string.IsNullOrWhiteSpace(NickName)) return;
try
{
// Appel de la méthode publique 'Nick' du Hub (void)
await _connection.InvokeAsync("Nick", NickName);
Messages.Add($"Demande de changement de pseudo vers '{NickName}' envoyée.");
}
catch (Exception ex)
{
Messages.Add($"Erreur Nick: {ex.Message}");
}
}
}
}

View file

@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
using System.Windows.Input;
namespace PostIt.ViewModels.Chat
{
internal class RelayCheckCommand : ICommand, IWithLabel
{
public string Label { get; }
private Func<Task> value;
public RelayCheckCommand(string v, Func<Task> value)
{
this.Label = v;
this.value = value;
}
public event EventHandler? CanExecuteChanged;
public bool CanExecute(object? parameter)
{
return value != null;
}
public void Execute(object? parameter)
{
if (value != null)
{
_ = value();
}
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;

View file

@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using PostIt.Models;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;

View file

@ -1,3 +1,5 @@
using PostIt.Models;
namespace PostIt.ViewModels;
public interface IActionStatusViewModel
@ -29,4 +31,4 @@ public static class ActionStatusViewModelExtensions
_ => StatusNotice.Info(normalizedMessage),
};
}
}
}

View file

@ -7,6 +7,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
@ -226,4 +227,4 @@ public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}
}

View file

@ -9,6 +9,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
using PostIt.Models;
[assembly: InternalsVisibleTo("PostIt.Tests")]

View file

@ -0,0 +1,43 @@
using System.Windows.Input;
using PostIt.Models;
namespace PostIt.ViewModels;
public sealed class StatusNotice
{
public string Message { get; }
public StatusSeverity Severity { get; }
public string Glyph { get; }
public string Background { get; }
public string BorderBrush { get; }
public string Foreground { get; }
public bool HasCheckCommand { get; }
public ICommand? CheckCommand { get; }
public string CheckCommandLabel { get; }
public StatusNotice(string message, StatusSeverity severity, ICommand? checkCommand)
{
Message = string.IsNullOrWhiteSpace(message) ? "Pret." : message;
Severity = severity;
(Glyph, Background, BorderBrush, Foreground) = severity switch
{
StatusSeverity.Error => ("!", "#7F1D1D", "#C62828", "#e1f0f6"),
StatusSeverity.Warning => ("~", "#7C4A03", "#E6A700", "#eaeaea"),
_ => ("i", "#E8F0FE", "#5B8DEF", "#1E3A8A"),
};
HasCheckCommand = checkCommand != null;
CheckCommand = checkCommand;
CheckCommandLabel = HasCheckCommand ? "Check" :
checkCommand == null ?
string.Empty :
checkCommand is IWithLabel labeled ? labeled.Label : string.Empty;
}
public static StatusNotice Info(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Info, checkCommand);
public static StatusNotice Warning(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Warning, checkCommand);
public static StatusNotice Error(string message, ICommand? checkCommand = null) => new(message, StatusSeverity.Error, checkCommand);
}

View file

@ -1,4 +1,5 @@
<ContentPage
x:Class="PostIt.Views.Chat.ChatPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
@ -9,38 +10,71 @@
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
x:Class="PostIt.Views.Chat.ChatPage"
x:DataType="vm:ChatViewModel" >
x:DataType="vm:ChatViewModel"
>
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto, *, Auto" Margin="10">
<Grid Margin="10" RowDefinitions="Auto, *, Auto">
<Grid Grid.Row="1" ColumnDefinitions="*, 200" Margin="0,0,0,10">
<Grid
Grid.Row="1"
Margin="0,0,0,10"
ColumnDefinitions="*, 200">
<!-- Journal des flux de messages & notifications -->
<Grid Grid.Column="0" RowDefinitions="Auto, *">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="10" Margin="0,0,0,5">
<TextBox Text="{Binding CurrentRoom}" Watermark="Nom du salon" Width="200" VerticalAlignment="Center"/>
<Button Content="Rejoindre Salon (Join)" Command="{Binding JoinRoomCommand}"/>
</StackPanel>
<!-- Journal des flux de messages & notifications -->
<Grid Grid.Column="0" RowDefinitions="Auto, *">
<StackPanel
Grid.Row="0"
Margin="0,0,0,5"
Orientation="Horizontal"
Spacing="10">
<TextBox
Width="200"
VerticalAlignment="Center"
Text="{Binding CurrentRoom}"
PlaceholderText="Nom du salon" />
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Messages}" BorderBrush="#E0E0E0" BorderThickness="1">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<ListBox
Grid.Row="1"
BorderBrush="#E0E0E0"
BorderThickness="1"
ItemsSource="{Binding Messages}">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
<Border Grid.Column="1" Margin="10,0,0,0" BorderBrush="#E0E0E0" BorderThickness="1" >
<Grid RowDefinitions="Auto, *">
<TextBlock Grid.Row="0" Text="Membres Présents" FontWeight="Bold" Margin="5" HorizontalAlignment="Center"/>
<ListBox Grid.Row="1" ItemsSource="{Binding ConnectedUsers}" BorderThickness="0"/>
</Grid>
</Border>
<!-- Liste des utilisateurs présents (MàJ via l'événement 'joint') -->
<Border
Grid.Column="1"
Margin="10,0,0,0"
BorderBrush="#E0E0E0"
BorderThickness="1">
<Grid RowDefinitions="Auto, *">
<TextBlock
Grid.Row="0"
Margin="5"
HorizontalAlignment="Center"
FontWeight="Bold"
Text="Membres Présents" />
<ListBox
Grid.Row="1"
BorderThickness="0"
ItemsSource="{Binding ConnectedUsers}" />
</Grid>
<postitControls:StatusBar Grid.Row="4" DataContext="{Binding Status}" x:Name="StatusBar" />
</Border>
</Grid>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="4"
DataContext="{Binding Status}" />
</Grid>
</ContentPage>

View file

@ -7,4 +7,5 @@ public partial class ChatPage : ContentPage
{
InitializeComponent();
}
}

View file

@ -16,6 +16,7 @@
<DockPanel LastChildFill="True">
<views:SessionStatusBanner
x:Name="SessionBanner"
DataContext="{Binding SessionStatus}"
DockPanel.Dock="Bottom"/>
<NavigationPage x:Name="NavRoot"/>
</DockPanel>

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;