Add channel creation and deletion context menu options

This commit is contained in:
Stone_Red
2026-07-16 15:32:17 +02:00
parent 3cd28ebdec
commit 9d15cf7fa3
7 changed files with 231 additions and 1 deletions
+26
View File
@@ -6,6 +6,7 @@ using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using System.Collections.ObjectModel;
@@ -192,6 +193,31 @@ public sealed class ConnectionService : IDisposable
await entry.Manager.SendMessageAsync(channelName, content);
}
public async Task<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{
throw new InvalidOperationException("Not connected to server");
}
string? wirePassword = null, saltB64 = null, wrappedKey = null;
if (password is not null)
{
var salt = RoomCrypto.GenerateSalt();
var derived = RoomCrypto.DeriveKeys(password, salt);
var roomKey = RoomCrypto.GenerateRoomKey();
wirePassword = derived.AuthKeyHex;
saltB64 = Convert.ToBase64String(salt);
wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey);
// Store the room key locally so we can decrypt messages immediately
entry.Manager.RoomKeys.StoreKey(name, roomKey);
}
ChannelDto? channel = await entry.ApiClient.CreateChannelAsync(name, topic, isPublic, wirePassword, saltB64, wrappedKey);
return channel;
}
public async Task<List<MessageModel>> JoinChannelAsync(string serverUrl, string channelName, string? password = null)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
+108
View File
@@ -496,6 +496,8 @@ public sealed class MainWindowViewModel : ViewModelBase
serverVm.ConnectRequested += () => HandleServerConnectRequested(serverVm);
serverVm.DisconnectRequested += () => HandleServerDisconnectRequested(serverVm);
serverVm.RemoveRequested += () => HandleServerRemoveRequested(serverVm);
serverVm.CreateChannelRequested += () => HandleCreateChannelRequested(serverVm);
serverVm.DeleteChannelRequested += () => HandleDeleteChannelRequested(serverVm);
_ = serverVm.WhenAnyValue(s => s.SelectedChannel)
.Where(channel => channel is not null)
.Subscribe(channel => Sidebar.SelectedChannel = channel!);
@@ -659,6 +661,112 @@ public sealed class MainWindowViewModel : ViewModelBase
});
}
private async Task HandleCreateChannelRequested(ServerViewModel server)
{
if (_mainWindow is null) return;
CreateChannelWindow dialog = new CreateChannelWindow();
bool? result = await dialog.ShowDialog<bool?>(_mainWindow);
if (result != true) return;
try
{
StatusText = "Creating channel...";
ChannelDto? channel = await ConnectionService.CreateChannelAsync(
server.ServerUrl, dialog.ResultName!, dialog.ResultTopic, dialog.ResultIsPublic, dialog.ResultPassword);
if (channel is null)
{
StatusText = "Failed to create channel";
return;
}
List<MessageModel> history = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name);
ChannelModel channelModel = new ChannelModel(
channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected);
ServerViewModel? serverVm = Sidebar.GetServer(server.ServerUrl);
if (serverVm is not null)
{
ChannelViewModel channelVm = new ChannelViewModel(channelModel);
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
serverVm.Channels.Add(channelVm);
serverVm.SelectedChannel = channelVm;
foreach (MessageModel msg in history)
{
channelVm.AddMessage(msg);
}
StatusText = $"Created #{channel.Name}";
});
}
}
catch (Exception ex)
{
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
"Error", $"Could not create channel:\n{ex.Message}", ButtonEnum.Ok);
_ = await box.ShowWindowDialogAsync(_mainWindow);
}
}
private async Task HandleDeleteChannelRequested(ServerViewModel server)
{
if (_mainWindow is null) return;
string channelName = Chat.CurrentChannelName;
if (string.IsNullOrEmpty(channelName) || !string.Equals(Chat.CurrentServerUrl, server.ServerUrl, StringComparison.OrdinalIgnoreCase))
{
StatusText = "No channel selected on this server";
return;
}
if (string.Equals(channelName, HubConstants.DefaultChannel, StringComparison.OrdinalIgnoreCase))
{
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
"Cannot Delete", $"The #{HubConstants.DefaultChannel} channel cannot be deleted.", ButtonEnum.Ok);
_ = await box.ShowWindowDialogAsync(_mainWindow);
return;
}
IMsBox<ButtonResult> confirmBox = MessageBoxManager.GetMessageBoxStandard(
"Delete Channel",
$"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;
try
{
await ConnectionService.DeleteChannelAsync(server.ServerUrl, channelName);
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ChannelViewModel? channelVm = server.Channels.FirstOrDefault(c => c.Name == channelName);
if (channelVm is not null)
{
_ = server.Channels.Remove(channelVm);
}
ChannelViewModel? defaultChannel = server.Channels.FirstOrDefault(c => c.Name == HubConstants.DefaultChannel)
?? server.Channels.FirstOrDefault();
server.SelectedChannel = defaultChannel;
StatusText = $"Deleted #{channelName}";
});
}
catch (Exception ex)
{
IMsBox<ButtonResult> box = MessageBoxManager.GetMessageBoxStandard(
"Error", $"Could not delete channel:\n{ex.Message}", ButtonEnum.Ok);
_ = await box.ShowWindowDialogAsync(_mainWindow);
}
}
private void HandleChannelSelected(ChannelViewModel? channel)
{
if (channel is null)
+38
View File
@@ -25,6 +25,18 @@ public sealed class ServerViewModel : ViewModelBase
remove => _removeRequested = (Func<Task>?)Delegate.Remove(_removeRequested, value);
}
public event Func<Task>? CreateChannelRequested
{
add => _createChannelRequested = (Func<Task>?)Delegate.Combine(_createChannelRequested, value);
remove => _createChannelRequested = (Func<Task>?)Delegate.Remove(_createChannelRequested, value);
}
public event Func<Task>? DeleteChannelRequested
{
add => _deleteChannelRequested = (Func<Task>?)Delegate.Combine(_deleteChannelRequested, value);
remove => _deleteChannelRequested = (Func<Task>?)Delegate.Remove(_deleteChannelRequested, value);
}
private bool _isConnected;
private bool _isConnecting;
private string? _connectedUser;
@@ -35,6 +47,10 @@ public sealed class ServerViewModel : ViewModelBase
private Func<Task>? _removeRequested;
private Func<Task>? _createChannelRequested;
private Func<Task>? _deleteChannelRequested;
public ServerModel Model { get; }
public string Name => Model.Name;
@@ -49,6 +65,10 @@ public sealed class ServerViewModel : ViewModelBase
public ReactiveCommand<Unit, Unit> RemoveCommand { get; }
public ReactiveCommand<Unit, Unit> CreateChannelCommand { get; }
public ReactiveCommand<Unit, Unit> DeleteChannelCommand { get; }
public ChannelViewModel? SelectedChannel
{
get;
@@ -135,6 +155,8 @@ public sealed class ServerViewModel : ViewModelBase
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync);
DisconnectCommand = ReactiveCommand.CreateFromTask(DisconnectAsync);
RemoveCommand = ReactiveCommand.CreateFromTask(RemoveAsync);
CreateChannelCommand = ReactiveCommand.CreateFromTask(CreateChannelAsync);
DeleteChannelCommand = ReactiveCommand.CreateFromTask(DeleteChannelAsync);
}
public void SyncFromModel()
@@ -168,4 +190,20 @@ public sealed class ServerViewModel : ViewModelBase
await _removeRequested();
}
}
private async Task CreateChannelAsync()
{
if (_createChannelRequested is not null)
{
await _createChannelRequested();
}
}
private async Task DeleteChannelAsync()
{
if (_deleteChannelRequested is not null)
{
await _deleteChannelRequested();
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Decho.Views.CreateChannelWindow"
Title="Create Channel"
Width="380"
SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False">
<StackPanel Margin="12" Spacing="8">
<TextBox Name="ChannelName" Watermark="Channel name" />
<TextBox Name="ChannelTopic" Watermark="Topic (optional)" />
<CheckBox Name="PublicCheckBox" Content="Public" IsChecked="True" />
<TextBox Name="ChannelPassword" Watermark="Password (optional for private channels)" PasswordChar="*" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
<Button Content="Cancel" IsCancel="True" />
<Button Content="Create" IsDefault="True" Click="OnCreateClicked" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,32 @@
using Avalonia.Controls;
namespace Decho.Views;
public partial class CreateChannelWindow : Window
{
public string? ResultName { get; private set; }
public string? ResultTopic { get; private set; }
public bool ResultIsPublic { get; private set; } = true;
public string? ResultPassword { get; private set; }
public CreateChannelWindow()
{
InitializeComponent();
}
private void OnCreateClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
string name = ChannelName.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
ChannelName.Focus();
return;
}
ResultName = name;
ResultTopic = string.IsNullOrWhiteSpace(ChannelTopic.Text) ? null : ChannelTopic.Text.Trim();
ResultIsPublic = PublicCheckBox.IsChecked == true;
ResultPassword = string.IsNullOrWhiteSpace(ChannelPassword.Text) ? null : ChannelPassword.Text;
Close(true);
}
}
+7
View File
@@ -49,6 +49,13 @@
IsVisible="{Binding IsConnected}" />
<MenuItem Header="Remove Server"
Command="{Binding RemoveCommand}" />
<Separator />
<MenuItem Header="Create Channel"
Command="{Binding CreateChannelCommand}"
IsVisible="{Binding IsConnected}" />
<MenuItem Header="Delete Channel"
Command="{Binding DeleteChannelCommand}"
IsVisible="{Binding IsConnected}" />
</ContextMenu>
</Button.ContextMenu>
</Button>