diff --git a/src/Decho/Models/StagedFile.cs b/src/Decho/Models/StagedFile.cs new file mode 100644 index 0000000..a088a8c --- /dev/null +++ b/src/Decho/Models/StagedFile.cs @@ -0,0 +1,3 @@ +namespace Decho.Models; + +public sealed record StagedFile(string FilePath, string FileName); \ No newline at end of file diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index c61add0..16fed39 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -95,6 +95,57 @@ public sealed class ConnectionService : IDisposable await entry.Manager.SendMessageAsync(channelName, content); } + public async Task SendMessageWithAttachmentsAsync(string serverUrl, string channelName, string content, IReadOnlyList filePaths, string? size = null) + { + if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) + { + throw new InvalidOperationException("Not connected to server"); + } + + entry.Manager.RoomKeys.TryGetKey(channelName, out byte[]? roomKey); + List attachments = new List(filePaths.Count); + + foreach (string filePath in filePaths) + { + string fileName = Path.GetFileName(filePath); + OutgoingAttachment attachment; + + if (roomKey is not null && roomKey.Length > 0) + { + byte[] bytes = await File.ReadAllBytesAsync(filePath); + string declaredKind; + string? preview = null; + + await using (MemoryStream ms = new MemoryStream(bytes)) + { + if (FileValidationHelper.IsValidImage(ms)) + { + declaredKind = "image"; + (int w, int h) = ImageToAsciiService.GetDimensions(size); + ms.Position = 0; + preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); + } + else + { + declaredKind = FileValidationHelper.IsAudioFile(fileName) ? "audio" : "file"; + } + } + + byte[] encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey); + attachment = new OutgoingAttachment(new MemoryStream(encryptedBlob), fileName, declaredKind, preview); + } + else + { + byte[] bytes = await File.ReadAllBytesAsync(filePath); + attachment = new OutgoingAttachment(new MemoryStream(bytes), fileName); + } + + attachments.Add(attachment); + } + + _ = await entry.ApiClient.SendMessageWithAttachmentsAsync(channelName, content, attachments, size); + } + public async Task CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, 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 1902562..b53926f 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -56,7 +56,6 @@ public sealed class MainWindowViewModel : ViewModelBase Chat.Composer.SendRequested += HandleSendRequested; Chat.Composer.CommandRequested += HandleCommandAsync; - Chat.Composer.FileUploadRequested += HandleFileUploadRequested; Chat.LoadMoreRequested += HandleLoadMoreRequested; WireCommandHandlerEvents(); @@ -707,13 +706,32 @@ public sealed class MainWindowViewModel : ViewModelBase return result.Message; } - private void HandleSendRequested(string serverUrl, string text) + private void HandleSendRequested(string serverUrl, string text, IReadOnlyList filePaths) { if (string.IsNullOrEmpty(Chat.CurrentChannelName)) { return; } + if (filePaths.Count > 0) + { + _ = Task.Run(async () => + { + try + { + await ConnectionService.SendMessageWithAttachmentsAsync(serverUrl, Chat.CurrentChannelName, text, filePaths); + Avalonia.Threading.Dispatcher.UIThread.Post(() => + StatusText = "Message sent"); + } + catch (Exception ex) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + StatusText = $"Send failed: {ex.Message}"); + } + }); + return; + } + if (_commandHandler.IsCommand(text)) { _ = HandleCommandAsync(text); @@ -736,29 +754,6 @@ public sealed class MainWindowViewModel : ViewModelBase }); } - private void HandleFileUploadRequested(string serverUrl, string filePath) - { - if (string.IsNullOrEmpty(Chat.CurrentChannelName)) - { - return; - } - - _ = Task.Run(async () => - { - try - { - await ConnectionService.UploadFileAsync(serverUrl, Chat.CurrentChannelName, filePath, null); - Avalonia.Threading.Dispatcher.UIThread.Post(() => - StatusText = "File uploaded"); - } - catch (Exception ex) - { - Avalonia.Threading.Dispatcher.UIThread.Post(() => - StatusText = $"Upload failed: {ex.Message}"); - } - }); - } - private async Task HandleCreateChannelRequested(ServerViewModel server) { if (_mainWindow is null) diff --git a/src/Decho/ViewModels/MessageComposerViewModel.cs b/src/Decho/ViewModels/MessageComposerViewModel.cs index e8762cf..71997d8 100644 --- a/src/Decho/ViewModels/MessageComposerViewModel.cs +++ b/src/Decho/ViewModels/MessageComposerViewModel.cs @@ -1,4 +1,7 @@ +using Decho.Models; + using EchoHub.Client.Commands; +using EchoHub.Core.Constants; using System.Collections.ObjectModel; using System.Reactive; @@ -7,16 +10,24 @@ namespace Decho.ViewModels; public sealed class MessageComposerViewModel : ViewModelBase { - public event Action? SendRequested; + public event Action>? SendRequested; public event Func>? CommandRequested; - public event Action? FileUploadRequested; - private readonly ObservableCollection _onlineUsers = []; private readonly ObservableCollection _channelNames = []; private CommandHandler? _commandHandler; + public ObservableCollection StagedFiles { get; } = []; + + public bool HasStagedFiles => StagedFiles.Count > 0; + + public string StagedFilesSummary + { + get; + private set => this.RaiseAndSetIfChanged(ref field, value); + } = string.Empty; + public string Draft { get; @@ -51,7 +62,10 @@ public sealed class MessageComposerViewModel : ViewModelBase Autocomplete = new AutocompleteController([mentionProvider, channelProvider]); - IObservable canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft)); + IObservable canSend = this.WhenAnyValue( + x => x.Draft, + x => x.HasStagedFiles, + (draft, hasFiles) => !string.IsNullOrWhiteSpace(draft) || hasFiles); SendCommand = ReactiveCommand.Create(Send, canSend); _ = this.WhenAnyValue(x => x.Draft).Subscribe(OnDraftChanged); @@ -75,12 +89,38 @@ public sealed class MessageComposerViewModel : ViewModelBase } } - public void RequestFileUpload(string filePath) + public void StageFiles(IEnumerable filePaths) { - if (!string.IsNullOrEmpty(ServerUrl)) + int remaining = HubConstants.MaxAttachmentsPerMessage - StagedFiles.Count; + foreach (string path in filePaths) { - FileUploadRequested?.Invoke(ServerUrl, filePath); + if (remaining <= 0) + { + break; + } + + if (StagedFiles.Any(f => f.FilePath == path)) + { + continue; + } + + StagedFiles.Add(new StagedFile(path, Path.GetFileName(path))); + remaining--; } + + UpdateStagedSummary(); + } + + public void RemoveStagedFile(StagedFile file) + { + _ = StagedFiles.Remove(file); + UpdateStagedSummary(); + } + + public void ClearStagedFiles() + { + StagedFiles.Clear(); + UpdateStagedSummary(); } public void SetServer(string serverUrl, bool isConnected = true) @@ -113,6 +153,14 @@ public sealed class MessageComposerViewModel : ViewModelBase Autocomplete.Reset(); } + private void UpdateStagedSummary() + { + this.RaisePropertyChanged(nameof(HasStagedFiles)); + StagedFilesSummary = StagedFiles.Count > 0 + ? $"{StagedFiles.Count} file(s) staged" + : string.Empty; + } + private void OnDraftChanged(string? draft) { Autocomplete.Update(draft ?? string.Empty); @@ -121,20 +169,18 @@ public sealed class MessageComposerViewModel : ViewModelBase private void Send() { string text = Draft.Trim(); - if (string.IsNullOrWhiteSpace(text)) - { - return; - } - Draft = string.Empty; if (_commandHandler is not null && _commandHandler.IsCommand(text)) { _ = (CommandRequested?.Invoke(text)); + return; } - else - { - SendRequested?.Invoke(ServerUrl, text); - } + + List filePaths = StagedFiles.Select(f => f.FilePath).ToList(); + StagedFiles.Clear(); + UpdateStagedSummary(); + + SendRequested?.Invoke(ServerUrl, text, filePaths); } } \ No newline at end of file diff --git a/src/Decho/Views/MessageComposerView.axaml b/src/Decho/Views/MessageComposerView.axaml index fb5e191..b8c74f3 100644 --- a/src/Decho/Views/MessageComposerView.axaml +++ b/src/Decho/Views/MessageComposerView.axaml @@ -1,68 +1,113 @@ - - - - - - - - - - - - - - - - - - - - + + + +