Add online users panel

This commit is contained in:
Stone_Red
2026-07-16 16:45:56 +02:00
parent 9d15cf7fa3
commit 3f6f6d3adb
10 changed files with 342 additions and 30 deletions
+3 -3
View File
@@ -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);
+69
View File
@@ -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<UserViewModel> 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<Unit, Unit> 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<UserPresenceDto> 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;
}
}
+63 -4
View File
@@ -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<UserPresenceDto> users = await ConnectionService.GetOnlineUsersAsync(serverUrl, channelName);
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
Chat.SetOnlineUsers(users);
});
}
catch
{
// silently ignore
}
}
private async Task<string?> 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<bool?>(_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;
}
}
+84
View File
@@ -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;
}
}
+21 -4
View File
@@ -2,15 +2,32 @@
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">
<Grid RowDefinitions="Auto, *, Auto" Margin="10">
<Grid RowDefinitions="Auto, Auto, *, Auto" Margin="10">
<Grid ColumnDefinitions="*, Auto">
<StackPanel Orientation="Horizontal">
<TextBlock FontWeight="Bold"
FontSize="16"
Margin="0 0 0 8"
Margin="0 0 4 4"
Text="{Binding ChannelTitle}" />
<views:MessageListView Grid.Row="1" />
<views:MessageComposerView Grid.Row="2"
<TextBlock Text="{Binding ChannelTopic}"
FontSize="12"
VerticalAlignment="Center"
Opacity="0.7" />
</StackPanel>
<Button i:Attached.Icon="fa-users"
Grid.Column="1"
Margin="-10"
Background="Transparent"
Foreground="{DynamicResource UiTheme08}"
Command="{Binding ToggleUsersPanelCommand}"
VerticalAlignment="Top"
HorizontalAlignment="Right" />
</Grid>
<views:MessageListView Grid.Row="2" />
<views:MessageComposerView Grid.Row="3"
DataContext="{Binding Composer}" />
</Grid>
</UserControl>
+1 -1
View File
@@ -19,7 +19,7 @@ public partial class CreateChannelWindow : Window
string name = ChannelName.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
ChannelName.Focus();
_ = ChannelName.Focus();
return;
}
+4 -1
View File
@@ -16,7 +16,7 @@
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid RowDefinitions="Auto, *" ColumnDefinitions="Auto, *">
<Grid RowDefinitions="Auto, *" ColumnDefinitions="Auto, *, Auto">
<DockPanel HorizontalAlignment="Stretch">
<TextBlock FontSize="20"
FontWeight="Bold"
@@ -44,5 +44,8 @@
<views:ChatView Grid.RowSpan="2"
Grid.Column="1"
DataContext="{Binding Chat}" />
<views:OnlineUsersView Grid.RowSpan="2"
Grid.Column="2"
DataContext="{Binding Chat}" />
</Grid>
</Window>
+37
View File
@@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.OnlineUsersView"
x:DataType="vm:ChatViewModel">
<Border Background="{DynamicResource UiTheme00}"
Width="200"
IsVisible="{Binding ShowOnlineUsers}">
<StackPanel>
<TextBlock FontWeight="Bold"
FontSize="13"
Margin="8 6"
Text="{Binding OnlineUserCount, StringFormat='Online — {0}'}" />
<ItemsControl ItemsSource="{Binding OnlineUsers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UserViewModel">
<Grid ColumnDefinitions="Auto, *" Margin="8 6">
<Ellipse Width="8" Height="8"
Fill="{Binding StatusColor}"
VerticalAlignment="Center"
Margin="0 0 6 0" />
<TextBlock Grid.Column="1"
Text="{Binding FullName}"
Foreground="{Binding DisplayColor}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
FontSize="12"
Cursor="Hand"
PointerPressed="OnUserNamePointerPressed" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</UserControl>
+43
View File
@@ -0,0 +1,43 @@
using Avalonia.Controls;
using Decho.ViewModels;
using EchoHub.Core.DTOs;
namespace Decho.Views;
public partial class OnlineUsersView : UserControl
{
public OnlineUsersView()
{
InitializeComponent();
}
private async void OnUserNamePointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (sender is not TextBlock textBlock || textBlock.DataContext is not UserViewModel user)
{
return;
}
if (TopLevel.GetTopLevel(this) is not Window parent)
{
return;
}
if (parent.DataContext is not MainWindowViewModel mainVm)
{
return;
}
string serverUrl = mainVm.Chat.CurrentServerUrl;
UserProfileDto? profile = await mainVm.ConnectionService.GetUserProfileAsync(serverUrl, user.Username);
if (profile is null)
{
return;
}
ProfileWindow dialog = new ProfileWindow(profile);
await dialog.ShowDialog(parent);
}
}
+1 -1
View File
@@ -49,7 +49,7 @@
IsVisible="{Binding IsConnected}" />
<MenuItem Header="Remove Server"
Command="{Binding RemoveCommand}" />
<Separator />
<Separator IsVisible="{Binding IsConnected}" />
<MenuItem Header="Create Channel"
Command="{Binding CreateChannelCommand}"
IsVisible="{Binding IsConnected}" />