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:
parent
51e445b5ba
commit
9369ff4fcd
4 changed files with 126 additions and 34 deletions
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue