Add support for multiple file attachments in message composer

This commit is contained in:
Stone_Red
2026-07-17 20:36:30 +02:00
parent 27c6b320b2
commit 8a5d7532cf
6 changed files with 275 additions and 109 deletions
+3
View File
@@ -0,0 +1,3 @@
namespace Decho.Models;
public sealed record StagedFile(string FilePath, string FileName);
+51
View File
@@ -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<string> 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<OutgoingAttachment> attachments = new List<OutgoingAttachment>(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<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null)
{
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
+20 -25
View File
@@ -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<string> 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)
@@ -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<string, string>? SendRequested;
public event Action<string, string, IReadOnlyList<string>>? SendRequested;
public event Func<string, Task<string?>>? CommandRequested;
public event Action<string, string>? FileUploadRequested;
private readonly ObservableCollection<UserViewModel> _onlineUsers = [];
private readonly ObservableCollection<string> _channelNames = [];
private CommandHandler? _commandHandler;
public ObservableCollection<StagedFile> 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<bool> canSend = this.WhenAnyValue(x => x.Draft, draft => !string.IsNullOrWhiteSpace(draft));
IObservable<bool> 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<string> 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<string> filePaths = StagedFiles.Select(f => f.FilePath).ToList();
StagedFiles.Clear();
UpdateStagedSummary();
SendRequested?.Invoke(ServerUrl, text, filePaths);
}
}
+46 -1
View File
@@ -1,10 +1,54 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels"
xmlns:models="using:Decho.Models"
xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel">
<Grid>
<Grid RowDefinitions="Auto,*">
<Border IsVisible="{Binding HasStagedFiles}"
Background="{DynamicResource UiTheme02}"
CornerRadius="4"
Padding="6"
Margin="0 0 0 4">
<DockPanel>
<Button DockPanel.Dock="Right"
Content="Clear"
FontSize="11"
Padding="4 0"
HorizontalAlignment="Right"
Click="OnClearStagedClick" />
<ItemsControl ItemsSource="{Binding StagedFiles}"
VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:StagedFile">
<Border Background="{DynamicResource UiTheme03}"
CornerRadius="3"
Padding="4 2"
Margin="0 0 4 2">
<DockPanel>
<TextBlock Text="{Binding FileName}"
FontSize="11"
VerticalAlignment="Center" />
<Button Content="x"
FontSize="10"
Padding="2 0"
Margin="4 0 0 0"
Cursor="Hand"
Click="OnRemoveStagedClick" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -65,4 +109,5 @@
i:Attached.Icon="fa-paperclip"
IsEnabled="{Binding IsConnected}" />
</Grid>
</Grid>
</UserControl>
+35 -9
View File
@@ -4,6 +4,7 @@ using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Decho.Models;
using Decho.ViewModels;
using System.Globalization;
@@ -134,17 +135,40 @@ public partial class MessageComposerView : UserControl
IReadOnlyList<IStorageFile> files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
AllowMultiple = false,
Title = "Select a file to upload",
AllowMultiple = true,
Title = "Select files to attach",
});
IStorageFile? file = files?.FirstOrDefault();
if (file?.TryGetLocalPath() is string path)
List<string> paths = [];
foreach (IStorageFile file in files)
{
vm.RequestFileUpload(path);
if (file.TryGetLocalPath() is string path)
{
paths.Add(path);
}
}
if (paths.Count > 0)
{
vm.StageFiles(paths);
}
}
private void OnRemoveStagedClick(object? sender, RoutedEventArgs e)
{
if (sender is Button button && button.DataContext is StagedFile file)
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
vm?.RemoveStagedFile(file);
}
}
private void OnClearStagedClick(object? sender, RoutedEventArgs e)
{
MessageComposerViewModel? vm = this.GetDataContext<MessageComposerViewModel>();
vm?.ClearStagedFiles();
}
private void OnDragOver(object? sender, DragEventArgs e)
{
#pragma warning disable CS0618
@@ -164,14 +188,16 @@ public partial class MessageComposerView : UserControl
}
#pragma warning disable CS0618
string? paths = e.Data.GetFiles()?
List<string> paths = e.Data.GetFiles()?
.Select(f => f.TryGetLocalPath())
.FirstOrDefault(p => p is not null);
.Where(p => p is not null)
.Cast<string>()
.ToList() ?? [];
#pragma warning restore CS0618
if (paths is string path)
if (paths.Count > 0)
{
vm.RequestFileUpload(path);
vm.StageFiles(paths);
}
}
}