From 9d15cf7fa3816da1237a18a9489b5819386841b1 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:32:17 +0200 Subject: [PATCH] Add channel creation and deletion context menu options --- src/Decho/Services/ConnectionService.cs | 26 +++++ src/Decho/ViewModels/MainWindowViewModel.cs | 108 +++++++++++++++++++ src/Decho/ViewModels/ServerViewModel.cs | 38 +++++++ src/Decho/Views/CreateChannelWindow.axaml | 19 ++++ src/Decho/Views/CreateChannelWindow.axaml.cs | 32 ++++++ src/Decho/Views/SidebarView.axaml | 7 ++ src/EchoHub | 2 +- 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/Decho/Views/CreateChannelWindow.axaml create mode 100644 src/Decho/Views/CreateChannelWindow.axaml.cs diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index fa37607..ce9003b 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -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 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> JoinChannelAsync(string serverUrl, string channelName, string? password = null) { if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index 1ab63d2..7260e5a 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -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(_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 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 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 box = MessageBoxManager.GetMessageBoxStandard( + "Cannot Delete", $"The #{HubConstants.DefaultChannel} channel cannot be deleted.", ButtonEnum.Ok); + _ = await box.ShowWindowDialogAsync(_mainWindow); + return; + } + + IMsBox 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 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) diff --git a/src/Decho/ViewModels/ServerViewModel.cs b/src/Decho/ViewModels/ServerViewModel.cs index 85e9c58..dc62523 100644 --- a/src/Decho/ViewModels/ServerViewModel.cs +++ b/src/Decho/ViewModels/ServerViewModel.cs @@ -25,6 +25,18 @@ public sealed class ServerViewModel : ViewModelBase remove => _removeRequested = (Func?)Delegate.Remove(_removeRequested, value); } + public event Func? CreateChannelRequested + { + add => _createChannelRequested = (Func?)Delegate.Combine(_createChannelRequested, value); + remove => _createChannelRequested = (Func?)Delegate.Remove(_createChannelRequested, value); + } + + public event Func? DeleteChannelRequested + { + add => _deleteChannelRequested = (Func?)Delegate.Combine(_deleteChannelRequested, value); + remove => _deleteChannelRequested = (Func?)Delegate.Remove(_deleteChannelRequested, value); + } + private bool _isConnected; private bool _isConnecting; private string? _connectedUser; @@ -35,6 +47,10 @@ public sealed class ServerViewModel : ViewModelBase private Func? _removeRequested; + private Func? _createChannelRequested; + + private Func? _deleteChannelRequested; + public ServerModel Model { get; } public string Name => Model.Name; @@ -49,6 +65,10 @@ public sealed class ServerViewModel : ViewModelBase public ReactiveCommand RemoveCommand { get; } + public ReactiveCommand CreateChannelCommand { get; } + + public ReactiveCommand 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(); + } + } } \ No newline at end of file diff --git a/src/Decho/Views/CreateChannelWindow.axaml b/src/Decho/Views/CreateChannelWindow.axaml new file mode 100644 index 0000000..b7c7775 --- /dev/null +++ b/src/Decho/Views/CreateChannelWindow.axaml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/src/EchoHub b/src/EchoHub index ce41ef9..aae7880 160000 --- a/src/EchoHub +++ b/src/EchoHub @@ -1 +1 @@ -Subproject commit ce41ef9c0ae18d8f118d5caa68f8a4c29b7a7700 +Subproject commit aae788028eaca52a32ef69c1ed4d7939086cec46