feat(chat): modele multi-salles cote client + fix serialisation ChatRoomInfo

Client PostIt:
- JoinedRooms, SelectedRoom, messages et utilisateurs par salle
  (FocusedMessages/FocusedUsers), OnRoomJoined alimente depuis la
  valeur de retour du Join
- ChatPage : ComboBox de choix de la salle en focus, listes bindees
  sur les vues de focus

Serveur:
- AddSignalR().AddJsonProtocol(IncludeFields=true) : ChatRoomInfo
  n'expose que des champs publics, STJ ne les serialisait pas et le
  client recevait des objets vides (JoinedRooms jamais alimente)
This commit is contained in:
Paul Schneider 2026-10-04 00:35:26 +01:00
commit 9369ff4fcd
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
4 changed files with 126 additions and 34 deletions

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
@ -31,6 +32,7 @@ namespace PostIt.ViewModels.Chat
_api = api; _api = api;
Status = new StatusNotice("Déconnecté", StatusSeverity.Info, Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync())); new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
FocusedMessages = _systemMessages;
} }
private HubConnection? _connection; private HubConnection? _connection;
@ -48,17 +50,51 @@ namespace PostIt.ViewModels.Chat
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))] [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial bool IsConnected { get; set; } = false; public partial bool IsConnected { get; set; } = false;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial bool HasJoinedRoom { get; set; } = false;
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))] [NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial string MessageToSend { get; set; } = ""; public partial string MessageToSend { get; set; } = "";
// Listes dynamiques pour l'UI // Salles jointes (choix de la salle en focus dans la vue).
public ObservableCollection<string> Messages { get; } = new(); public ObservableCollection<string> JoinedRooms { get; } = new();
public ObservableCollection<string> ConnectedUsers { get; } = new();
// Salle en focus ; null = aucune salle, le journal affiche
// alors les notifications système.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendMessageCommand))]
public partial string? SelectedRoom { get; set; }
// Notifications globales (connexion, erreurs de join, ...)
private readonly ObservableCollection<string> _systemMessages = new();
// Messages et utilisateurs présents, indexés par salle.
private readonly Dictionary<string, ObservableCollection<string>> _messagesByRoom = new();
private readonly Dictionary<string, ObservableCollection<string>> _usersByRoom = new();
private static readonly ObservableCollection<string> _noUsers = new();
// Vues "focus" exposées à la page : pointent vers la salle
// sélectionnée, ou vers le journal système / une liste vide.
[ObservableProperty]
public partial ObservableCollection<string> FocusedMessages { get; set; }
[ObservableProperty]
public partial ObservableCollection<string> FocusedUsers { get; set; } = _noUsers;
partial void OnSelectedRoomChanged(string? value)
{
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
? msgs : _systemMessages;
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
? users : _noUsers;
}
// Trace une notification système dans la vue courante
// (et dans le journal global pour consultation ultérieure).
private void Log(string line)
{
_systemMessages.Add(line);
if (!ReferenceEquals(FocusedMessages, _systemMessages))
FocusedMessages.Add(line);
}
// Commandes pour les boutons // Commandes pour les boutons
[RelayCommand] [RelayCommand]
@ -69,23 +105,23 @@ namespace PostIt.ViewModels.Chat
// Le serveur (ChatHub.Send) refuse tout message d'un utilisateur // Le serveur (ChatHub.Send) refuse tout message d'un utilisateur
// qui n'a pas joint le salon ("NoJoinNoSend") : on garde la // qui n'a pas joint le salon ("NoJoinNoSend") : on garde la
// commande inactive tant que le Join n'a pas abouti. // commande inactive tant qu'aucune salle n'est en focus.
private bool CanSendMessage() => private bool CanSendMessage() =>
IsConnected && HasJoinedRoom && !string.IsNullOrWhiteSpace(MessageToSend); IsConnected && SelectedRoom != null && !string.IsNullOrWhiteSpace(MessageToSend);
[RelayCommand(CanExecute = nameof(CanSendMessage))] [RelayCommand(CanExecute = nameof(CanSendMessage))]
private async Task SendMessageAsync() private async Task SendMessageAsync()
{ {
if (_connection == null) return; if (_connection == null || SelectedRoom == null) return;
var text = MessageToSend.Trim(); var text = MessageToSend.Trim();
try try
{ {
await _connection.InvokeAsync("Send", CurrentRoom, text); await _connection.InvokeAsync("Send", SelectedRoom, text);
MessageToSend = string.Empty; MessageToSend = string.Empty;
} }
catch (Exception ex) catch (Exception ex)
{ {
Messages.Add($"Erreur d'envoi : {ex.Message}"); Log($"Erreur d'envoi : {ex.Message}");
} }
} }
@ -101,7 +137,11 @@ namespace PostIt.ViewModels.Chat
_connection = null; _connection = null;
} }
IsConnected = false; IsConnected = false;
HasJoinedRoom = false; SelectedRoom = null;
JoinedRooms.Clear();
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
Status = new StatusNotice("Déconnecté", StatusSeverity.Info, Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
new RelayCheckCommand("Connecter", async () => await ConnectAsync())); new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
} }
@ -152,7 +192,7 @@ namespace PostIt.ViewModels.Chat
_connection.On<string, string, string>("notifyUser", (type, user, msg) => _connection.On<string, string, string>("notifyUser", (type, user, msg) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Messages.Add($"[{type}] {user}: {msg}")); Log($"[{type}] {user}: {msg}"));
}); });
// 2. Ecoute des notifications de salon (notifyRoom) // 2. Ecoute des notifications de salon (notifyRoom)
@ -160,29 +200,32 @@ namespace PostIt.ViewModels.Chat
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
Messages.Add($"*{user} a interagi avec le salon {room} (Action: {type})"); if (_messagesByRoom.TryGetValue(room, out var roomLog))
if (type == "UserJoin" && !ConnectedUsers.Contains(user)) roomLog.Add($"*{user} a interagi avec le salon {room} (Action: {type})");
ConnectedUsers.Add(user); else
Log($"*{user} a interagi avec le salon {room} (Action: {type})");
if (type == "UserJoin" && _usersByRoom.TryGetValue(room, out var users)
&& !users.Contains(user))
users.Add(user);
}); });
}); });
// 3. Réception de l'état complet suite au Join réussi (joint) // 3. Réception de l'état complet suite au Join réussi (joint)
_connection.On<ChatRoomInfo>("joint", (roomInfo) => _connection.On<ChatRoomInfo>("joint", (roomInfo) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() => OnRoomJoined(roomInfo));
{
Messages.Add($"Salon rejoint avec succès : {roomInfo.Name}");
HasJoinedRoom = true;
ConnectedUsers.Clear();
foreach (var u in roomInfo.Users) ConnectedUsers.Add(u);
});
}); });
// 4. Réception des messages du salon (diffusés par ChatHub.Send) // 4. Réception des messages de salon (diffusés par ChatHub.Send)
_connection.On<ChatMessage>("ReceiveMessage", (msg) => _connection.On<ChatMessage>("ReceiveMessage", (msg) =>
{ {
Avalonia.Threading.Dispatcher.UIThread.Post(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Messages.Add($"[{msg.Room}] {msg.Name} : {msg.Message}")); {
var room = msg.Room ?? "?";
if (!_messagesByRoom.TryGetValue(room, out var roomLog))
roomLog = _messagesByRoom[room] = new();
roomLog.Add($"{msg.Name} : {msg.Message}");
});
}); });
await _connection.StartAsync(); await _connection.StartAsync();
@ -231,10 +274,30 @@ namespace PostIt.ViewModels.Chat
} }
} }
// Alimente JoinedRooms et les collections par salle à partir
// du ChatRoomInfo renvoyé par le hub (valeur de retour du Join
// et/ou événement "joint"). Idempotent.
private void OnRoomJoined(ChatRoomInfo roomInfo)
{
// ChatHub.Join renseigne toujours Name (salle existante ou
// créée) ; un Name nul signalerait un payload inattendu.
var roomName = roomInfo.Name ?? CurrentRoom;
if (roomInfo.Name == null)
Log("OnRoomJoined : nom de salle absent du payload, repli sur la salle demandée.");
if (!_messagesByRoom.TryGetValue(roomName, out var roomLog))
roomLog = _messagesByRoom[roomName] = new();
roomLog.Add($"Salon rejoint avec succès : {roomName}");
var users = _usersByRoom[roomName] = new();
foreach (var u in roomInfo.Users) users.Add(u);
if (!JoinedRooms.Contains(roomName))
JoinedRooms.Add(roomName);
SelectedRoom = roomName;
}
private Task CheckConnectionAsync() private Task CheckConnectionAsync()
{ {
var state = _connection?.State.ToString() ?? "aucune connexion"; var state = _connection?.State.ToString() ?? "aucune connexion";
Messages.Add($"État de la connexion : {state}"); Log($"État de la connexion : {state}");
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -242,19 +305,26 @@ namespace PostIt.ViewModels.Chat
{ {
if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return; if (_connection == null || string.IsNullOrWhiteSpace(CurrentRoom)) return;
// Salle déjà jointe : on se contente de la mettre en focus.
if (JoinedRooms.Contains(CurrentRoom))
{
SelectedRoom = CurrentRoom;
return;
}
// InvokeCoreAsync exige une connexion active : avec la // InvokeCoreAsync exige une connexion active : avec la
// reconnexion automatique, l'état peut être Reconnecting au // reconnexion automatique, l'état peut être Reconnecting au
// moment de l'appel. On tente une (re)connexion si nécessaire. // moment de l'appel. On tente une (re)connexion si nécessaire.
if (_connection.State != HubConnectionState.Connected) if (_connection.State != HubConnectionState.Connected)
{ {
Messages.Add($"Connexion non active ({_connection.State}), tentative de reconnexion..."); Log($"Connexion non active ({_connection.State}), tentative de reconnexion...");
try try
{ {
await _connection.StartAsync(); await _connection.StartAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Messages.Add($"Reconnexion impossible : {ex.Message}"); Log($"Reconnexion impossible : {ex.Message}");
return; return;
} }
} }
@ -265,12 +335,18 @@ namespace PostIt.ViewModels.Chat
var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom); var info = await _connection.InvokeAsync<ChatRoomInfo>("Join", CurrentRoom);
if (info == null) if (info == null)
{ {
Messages.Add("Erreur lors de la validation du salon par le serveur."); Log("Erreur lors de la validation du salon par le serveur.");
}
else
{
// On alimente le modèle depuis la valeur de retour,
// sans attendre l'événement "joint".
OnRoomJoined(info);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Messages.Add($"Erreur Join: {ex.Message}"); Log($"Erreur Join: {ex.Message}");
} }
} }
} }

View file

@ -36,13 +36,20 @@
Text="{Binding CurrentRoom}" Text="{Binding CurrentRoom}"
PlaceholderText="Nom du salon" /> PlaceholderText="Nom du salon" />
<Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" /> <Button Command="{Binding JoinRoomCommand}" Content="Rejoindre Salon (Join)" />
<!-- Choix de la salle en focus parmi celles jointes -->
<ComboBox
Width="180"
VerticalAlignment="Center"
ItemsSource="{Binding JoinedRooms}"
PlaceholderText="Salle active"
SelectedItem="{Binding SelectedRoom}" />
</StackPanel> </StackPanel>
<ListBox <ListBox
Grid.Row="1" Grid.Row="1"
BorderBrush="#E0E0E0" BorderBrush="#E0E0E0"
BorderThickness="1" BorderThickness="1"
ItemsSource="{Binding Messages}"> ItemsSource="{Binding FocusedMessages}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" /> <TextBlock Text="{Binding}" TextWrapping="Wrap" />
@ -67,7 +74,7 @@
<ListBox <ListBox
Grid.Row="1" Grid.Row="1"
BorderThickness="0" BorderThickness="0"
ItemsSource="{Binding ConnectedUsers}" /> ItemsSource="{Binding FocusedUsers}" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>
@ -80,7 +87,7 @@
Grid.Column="0" Grid.Column="0"
VerticalAlignment="Center" VerticalAlignment="Center"
AcceptsReturn="False" AcceptsReturn="False"
IsEnabled="{Binding HasJoinedRoom}" IsEnabled="{Binding !!SelectedRoom}"
PlaceholderText="Votre message..." PlaceholderText="Votre message..."
Text="{Binding MessageToSend}"> Text="{Binding MessageToSend}">
<TextBox.KeyBindings> <TextBox.KeyBindings>

View file

@ -66,7 +66,12 @@ internal class Program
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration); .AddYavscJwtBearer(builder.Configuration);
services.AddSignalR(); services.AddSignalR()
// ChatRoomInfo et d'autres DTO de hub exposent des champs
// publics (pas des propriétés) : STJ ne les sérialise pas
// sans IncludeFields, et le client recevrait des objets vides.
.AddJsonProtocol(options =>
options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton<IConnexionManager, HubConnectionManager>(); services.AddSingleton<IConnexionManager, HubConnectionManager>();
// DbContextBuilder // DbContextBuilder

View file

@ -152,7 +152,11 @@ namespace Yavsc.Server.Hubs
// in case in an additional connection, // in case in an additional connection,
// one only send info on room without // one only send info on room without
// warning any other user. // warning any other user.
_cxManager.TryGetChanInfo(roomName, out channelInfo); if (!_cxManager.TryGetChanInfo(roomName, out channelInfo))
{
_logger.LogError($"Failed to retrieve channel info for room: {roomName}");
return null;
}
} }
_logger.LogInformation($"returning channel info"); _logger.LogInformation($"returning channel info");