diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index ce9003b..eb8eb41 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -204,9 +204,9 @@ public sealed class ConnectionService : IDisposable if (password is not null) { - var salt = RoomCrypto.GenerateSalt(); - var derived = RoomCrypto.DeriveKeys(password, salt); - var roomKey = RoomCrypto.GenerateRoomKey(); + byte[] salt = RoomCrypto.GenerateSalt(); + RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(password, salt); + byte[] roomKey = RoomCrypto.GenerateRoomKey(); wirePassword = derived.AuthKeyHex; saltB64 = Convert.ToBase64String(salt); wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey); diff --git a/src/Decho/ViewModels/ChatViewModel.cs b/src/Decho/ViewModels/ChatViewModel.cs index a4d37d2..b18b2d0 100644 --- a/src/Decho/ViewModels/ChatViewModel.cs +++ b/src/Decho/ViewModels/ChatViewModel.cs @@ -1,4 +1,7 @@ +using EchoHub.Core.DTOs; + using System.Collections.ObjectModel; +using System.Reactive; namespace Decho.ViewModels; @@ -10,6 +13,8 @@ public sealed class ChatViewModel : ViewModelBase private set => this.RaiseAndSetIfChanged(ref field, value); } = []; + public ObservableCollection OnlineUsers { get; } = []; + public string ChannelTitle { get; @@ -32,6 +37,20 @@ public sealed class ChatViewModel : ViewModelBase private set => this.RaiseAndSetIfChanged(ref field, value); } + public bool ShowOnlineUsers + { + get => field; + set => this.RaiseAndSetIfChanged(ref field, value); + } + + public ReactiveCommand ToggleUsersPanelCommand { get; } + + public string OnlineUserCount + { + get => field; + set => this.RaiseAndSetIfChanged(ref field, value); + } = string.Empty; + public MessageComposerViewModel Composer { get; } public string CurrentServerUrl { get; private set; } = string.Empty; @@ -41,6 +60,12 @@ public sealed class ChatViewModel : ViewModelBase public ChatViewModel() { Composer = new MessageComposerViewModel(); + ToggleUsersPanelCommand = ReactiveCommand.Create(ToggleUsersPanel); + } + + private void ToggleUsersPanel() + { + ShowOnlineUsers = !ShowOnlineUsers; } public void SetChannel(ChannelViewModel? channel, string serverUrl = "", bool isServerConnected = true) @@ -53,6 +78,9 @@ public sealed class ChatViewModel : ViewModelBase HasTopic = false; CurrentChannelName = string.Empty; CurrentServerUrl = string.Empty; + ShowOnlineUsers = false; + OnlineUsers.Clear(); + OnlineUserCount = string.Empty; Composer.SetServer(string.Empty, isServerConnected); return; } @@ -64,6 +92,45 @@ public sealed class ChatViewModel : ViewModelBase CurrentChannelName = channel.Name; CurrentServerUrl = serverUrl; Composer.SetServer(serverUrl, isServerConnected); + + if (!isServerConnected) + { + ShowOnlineUsers = false; + OnlineUsers.Clear(); + OnlineUserCount = string.Empty; + } + } + + public void SetOnlineUsers(List users) + { + OnlineUsers.Clear(); + foreach (UserPresenceDto user in users) + { + OnlineUsers.Add(new UserViewModel(user)); + } + OnlineUserCount = $"{users.Count}"; + ShowOnlineUsers = true; + } + + public void AddOnlineUser(UserPresenceDto user) + { + if (OnlineUsers.All(u => u.Username != user.Username)) + { + OnlineUsers.Add(new UserViewModel(user)); + OnlineUserCount = $"{OnlineUsers.Count}"; + } + ShowOnlineUsers = OnlineUsers.Count > 0; + } + + public void RemoveOnlineUser(string username) + { + UserViewModel? user = OnlineUsers.FirstOrDefault(u => u.Username == username); + if (user is not null) + { + _ = OnlineUsers.Remove(user); + OnlineUserCount = $"{OnlineUsers.Count}"; + } + ShowOnlineUsers = OnlineUsers.Count > 0; } public void ClearMessages() @@ -72,5 +139,7 @@ public sealed class ChatViewModel : ViewModelBase ChannelTitle = "Select a channel"; ChannelTopic = null; HasTopic = false; + OnlineUsers.Clear(); + OnlineUserCount = string.Empty; } } \ No newline at end of file diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index 7260e5a..4b4fb2e 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -584,6 +584,24 @@ public sealed class MainWindowViewModel : ViewModelBase } }; + ConnectionService.UserJoined += (serverUrl, channelName, username) => + { + if (string.Equals(channelName, Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase) + && string.Equals(serverUrl, Chat.CurrentServerUrl, StringComparison.OrdinalIgnoreCase)) + { + _ = RefreshOnlineUsersAsync(serverUrl, channelName); + } + }; + + ConnectionService.UserLeft += (serverUrl, channelName) => + { + if (string.Equals(channelName, Chat.CurrentChannelName, StringComparison.OrdinalIgnoreCase) + && string.Equals(serverUrl, Chat.CurrentServerUrl, StringComparison.OrdinalIgnoreCase)) + { + _ = RefreshOnlineUsersAsync(serverUrl, channelName); + } + }; + ConnectionService.ErrorOccurred += (serverUrl, error) => { Avalonia.Threading.Dispatcher.UIThread.Post(() => @@ -593,6 +611,22 @@ public sealed class MainWindowViewModel : ViewModelBase }; } + private async Task RefreshOnlineUsersAsync(string serverUrl, string channelName) + { + try + { + List users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channelName); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Chat.SetOnlineUsers(users); + }); + } + catch + { + // silently ignore + } + } + private async Task HandleCommandAsync(string commandText) { CommandResult result = await _commandHandler.HandleAsync(commandText); @@ -663,11 +697,17 @@ public sealed class MainWindowViewModel : ViewModelBase private async Task HandleCreateChannelRequested(ServerViewModel server) { - if (_mainWindow is null) return; + if (_mainWindow is null) + { + return; + } CreateChannelWindow dialog = new CreateChannelWindow(); bool? result = await dialog.ShowDialog(_mainWindow); - if (result != true) return; + if (result != true) + { + return; + } try { @@ -716,7 +756,10 @@ public sealed class MainWindowViewModel : ViewModelBase private async Task HandleDeleteChannelRequested(ServerViewModel server) { - if (_mainWindow is null) return; + if (_mainWindow is null) + { + return; + } string channelName = Chat.CurrentChannelName; if (string.IsNullOrEmpty(channelName) || !string.Equals(Chat.CurrentServerUrl, server.ServerUrl, StringComparison.OrdinalIgnoreCase)) @@ -738,7 +781,10 @@ public sealed class MainWindowViewModel : ViewModelBase $"Are you sure you want to delete #{channelName}?\nThis will remove all messages permanently.", ButtonEnum.YesNo); ButtonResult confirm = await confirmBox.ShowWindowDialogAsync(_mainWindow); - if (confirm != ButtonResult.Yes) return; + if (confirm != ButtonResult.Yes) + { + return; + } try { @@ -814,6 +860,7 @@ public sealed class MainWindowViewModel : ViewModelBase } }); + _ = RefreshOnlineUsersAsync(serverUrl, channel.Name); return; } catch (EchoHub.Client.Services.ChannelPasswordRequiredException) @@ -830,6 +877,12 @@ public sealed class MainWindowViewModel : ViewModelBase if (string.IsNullOrEmpty(pwd)) { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Chat.OnlineUsers.Clear(); + Chat.ShowOnlineUsers = false; + Chat.OnlineUserCount = string.Empty; + }); return; } @@ -837,6 +890,12 @@ public sealed class MainWindowViewModel : ViewModelBase } catch { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Chat.OnlineUsers.Clear(); + Chat.ShowOnlineUsers = false; + Chat.OnlineUserCount = string.Empty; + }); return; } } diff --git a/src/Decho/ViewModels/UserViewModel.cs b/src/Decho/ViewModels/UserViewModel.cs new file mode 100644 index 0000000..21f5eff --- /dev/null +++ b/src/Decho/ViewModels/UserViewModel.cs @@ -0,0 +1,84 @@ +using Avalonia.Media; + +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; + +namespace Decho.ViewModels; + +public sealed class UserViewModel : ViewModelBase +{ + public string Username { get; } + public string DisplayName { get; } + public string? NicknameColor { get; } + public string? StatusMessage { get; } + public ServerRole Role { get; } + + public UserStatus Status + { + get => _status; + set + { + if (_status == value) + { + return; + } + + _status = value; + this.RaisePropertyChanged(); + this.RaisePropertyChanged(nameof(StatusColor)); + this.RaisePropertyChanged(nameof(StatusText)); + } + } + + private UserStatus _status; + + public string FullName => DisplayName ?? Username; + + public IBrush? DisplayColor + { + get + { + if (string.IsNullOrEmpty(NicknameColor)) + { + return Brushes.White; + } + + try + { + return new SolidColorBrush(Color.Parse(NicknameColor)); + } + catch + { + return null; + } + } + } + + public string StatusColor => Status switch + { + UserStatus.Online => "#4CAF50", + UserStatus.Away => "#FF9800", + UserStatus.DoNotDisturb => "#E53935", + UserStatus.Invisible => "#9E9E9E", + _ => "#9E9E9E", + }; + + public string StatusText => Status switch + { + UserStatus.Online => "Online", + UserStatus.Away => "Away", + UserStatus.DoNotDisturb => "Do Not Disturb", + UserStatus.Invisible => "Invisible", + _ => "Offline", + }; + + public UserViewModel(UserPresenceDto dto) + { + Username = dto.Username; + DisplayName = dto.DisplayName ?? dto.Username; + NicknameColor = dto.NicknameColor; + StatusMessage = dto.StatusMessage; + Role = dto.Role; + _status = dto.Status; + } +} diff --git a/src/Decho/Views/ChatView.axaml b/src/Decho/Views/ChatView.axaml index 25a81b9..efa157b 100644 --- a/src/Decho/Views/ChatView.axaml +++ b/src/Decho/Views/ChatView.axaml @@ -1,16 +1,33 @@ - - - - - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:vm="using:Decho.ViewModels" + xmlns:views="using:Decho.Views" + xmlns:i="https://github.com/projektanker/icons.avalonia" + x:Class="Decho.Views.ChatView" + x:DataType="vm:ChatViewModel"> + + + + + + +