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); 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) public async Task<ChannelDto?> CreateChannelAsync(string serverUrl, string name, string? topic, bool isPublic, string? password = null)
{ {
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) 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.SendRequested += HandleSendRequested;
Chat.Composer.CommandRequested += HandleCommandAsync; Chat.Composer.CommandRequested += HandleCommandAsync;
Chat.Composer.FileUploadRequested += HandleFileUploadRequested;
Chat.LoadMoreRequested += HandleLoadMoreRequested; Chat.LoadMoreRequested += HandleLoadMoreRequested;
WireCommandHandlerEvents(); WireCommandHandlerEvents();
@@ -707,13 +706,32 @@ public sealed class MainWindowViewModel : ViewModelBase
return result.Message; 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)) if (string.IsNullOrEmpty(Chat.CurrentChannelName))
{ {
return; 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)) if (_commandHandler.IsCommand(text))
{ {
_ = HandleCommandAsync(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) private async Task HandleCreateChannelRequested(ServerViewModel server)
{ {
if (_mainWindow is null) if (_mainWindow is null)
@@ -1,4 +1,7 @@
using Decho.Models;
using EchoHub.Client.Commands; using EchoHub.Client.Commands;
using EchoHub.Core.Constants;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reactive; using System.Reactive;
@@ -7,16 +10,24 @@ namespace Decho.ViewModels;
public sealed class MessageComposerViewModel : ViewModelBase 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 Func<string, Task<string?>>? CommandRequested;
public event Action<string, string>? FileUploadRequested;
private readonly ObservableCollection<UserViewModel> _onlineUsers = []; private readonly ObservableCollection<UserViewModel> _onlineUsers = [];
private readonly ObservableCollection<string> _channelNames = []; private readonly ObservableCollection<string> _channelNames = [];
private CommandHandler? _commandHandler; 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 public string Draft
{ {
get; get;
@@ -51,7 +62,10 @@ public sealed class MessageComposerViewModel : ViewModelBase
Autocomplete = new AutocompleteController([mentionProvider, channelProvider]); 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); SendCommand = ReactiveCommand.Create(Send, canSend);
_ = this.WhenAnyValue(x => x.Draft).Subscribe(OnDraftChanged); _ = 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) public void SetServer(string serverUrl, bool isConnected = true)
@@ -113,6 +153,14 @@ public sealed class MessageComposerViewModel : ViewModelBase
Autocomplete.Reset(); 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) private void OnDraftChanged(string? draft)
{ {
Autocomplete.Update(draft ?? string.Empty); Autocomplete.Update(draft ?? string.Empty);
@@ -121,20 +169,18 @@ public sealed class MessageComposerViewModel : ViewModelBase
private void Send() private void Send()
{ {
string text = Draft.Trim(); string text = Draft.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return;
}
Draft = string.Empty; Draft = string.Empty;
if (_commandHandler is not null && _commandHandler.IsCommand(text)) if (_commandHandler is not null && _commandHandler.IsCommand(text))
{ {
_ = (CommandRequested?.Invoke(text)); _ = (CommandRequested?.Invoke(text));
return;
} }
else
{ List<string> filePaths = StagedFiles.Select(f => f.FilePath).ToList();
SendRequested?.Invoke(ServerUrl, text); StagedFiles.Clear();
} UpdateStagedSummary();
SendRequested?.Invoke(ServerUrl, text, filePaths);
} }
} }
+104 -59
View File
@@ -1,68 +1,113 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Decho.ViewModels" xmlns:vm="using:Decho.ViewModels"
xmlns:models="using:Decho.Models"
xmlns:i="https://github.com/projektanker/icons.avalonia" xmlns:i="https://github.com/projektanker/icons.avalonia"
x:Class="Decho.Views.MessageComposerView" x:Class="Decho.Views.MessageComposerView"
x:DataType="vm:MessageComposerViewModel"> x:DataType="vm:MessageComposerViewModel">
<Grid> <Grid RowDefinitions="Auto,*">
<Grid.ColumnDefinitions> <Border IsVisible="{Binding HasStagedFiles}"
<ColumnDefinition Width="*" /> Background="{DynamicResource UiTheme02}"
<ColumnDefinition Width="Auto" /> CornerRadius="4"
<ColumnDefinition Width="Auto" /> Padding="6"
</Grid.ColumnDefinitions> Margin="0 0 0 4">
<Grid> <DockPanel>
<TextBox x:Name="MessageTextBox" <Button DockPanel.Dock="Right"
HorizontalAlignment="Stretch" Content="Clear"
Margin="0 0 5 0" FontSize="11"
Text="{Binding Draft, Mode=TwoWay}" Padding="4 0"
IsEnabled="{Binding IsConnected}" HorizontalAlignment="Right"
KeyDown="OnTextBoxKeyDown"> Click="OnClearStagedClick" />
<TextBox.KeyBindings> <ItemsControl ItemsSource="{Binding StagedFiles}"
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" /> VerticalAlignment="Center">
</TextBox.KeyBindings> <ItemsControl.ItemsPanel>
</TextBox> <ItemsPanelTemplate>
<Popup x:Name="MentionPopup" <WrapPanel />
Placement="TopEdgeAlignedLeft" </ItemsPanelTemplate>
PlacementTarget="{Binding #MessageTextBox}" </ItemsControl.ItemsPanel>
IsOpen="{Binding Autocomplete.ShowPopup}" <ItemsControl.ItemTemplate>
IsLightDismissEnabled="True" <DataTemplate x:DataType="models:StagedFile">
MinWidth="100" <Border Background="{DynamicResource UiTheme03}"
MaxHeight="140"> CornerRadius="3"
<ListBox ItemsSource="{Binding Autocomplete.FilteredItems}" Padding="4 2"
SelectedIndex="{Binding Autocomplete.SelectedIndex}" Margin="0 0 4 2">
BorderThickness="1" <DockPanel>
BorderBrush="{DynamicResource UiTheme08}" <TextBlock Text="{Binding FileName}"
Background="{DynamicResource UiTheme02}"> FontSize="11"
<ListBox.Styles> VerticalAlignment="Center" />
<Style Selector="ListBoxItem:selected"> <Button Content="x"
<Setter Property="Background" Value="#3D3D3D" /> FontSize="10"
</Style> Padding="2 0"
</ListBox.Styles> Margin="4 0 0 0"
<ListBox.ItemTemplate> Cursor="Hand"
<DataTemplate x:DataType="x:String"> Click="OnRemoveStagedClick" />
<TextBlock Text="{Binding}" </DockPanel>
FontSize="12" </Border>
Padding="4 2"
Cursor="Hand"
PointerPressed="OnMentionPointerPressed" />
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ItemsControl.ItemTemplate>
</ListBox> </ItemsControl>
</Popup> </DockPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid>
<TextBox x:Name="MessageTextBox"
HorizontalAlignment="Stretch"
Margin="0 0 5 0"
Text="{Binding Draft, Mode=TwoWay}"
IsEnabled="{Binding IsConnected}"
KeyDown="OnTextBoxKeyDown">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding SendCommand}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Popup x:Name="MentionPopup"
Placement="TopEdgeAlignedLeft"
PlacementTarget="{Binding #MessageTextBox}"
IsOpen="{Binding Autocomplete.ShowPopup}"
IsLightDismissEnabled="True"
MinWidth="100"
MaxHeight="140">
<ListBox ItemsSource="{Binding Autocomplete.FilteredItems}"
SelectedIndex="{Binding Autocomplete.SelectedIndex}"
BorderThickness="1"
BorderBrush="{DynamicResource UiTheme08}"
Background="{DynamicResource UiTheme02}">
<ListBox.Styles>
<Style Selector="ListBoxItem:selected">
<Setter Property="Background" Value="#3D3D3D" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}"
FontSize="12"
Padding="4 2"
Cursor="Hand"
PointerPressed="OnMentionPointerPressed" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Popup>
</Grid>
<Button Grid.Column="1"
Height="33"
Width="33"
Margin="0 0 5 0"
Content="Send"
i:Attached.Icon="fa-paper-plane"
Command="{Binding SendCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Grid.Column="2"
Height="33"
Width="33"
Click="OnFileUploadClicked"
i:Attached.Icon="fa-paperclip"
IsEnabled="{Binding IsConnected}" />
</Grid> </Grid>
<Button Grid.Column="1"
Height="33"
Width="33"
Margin="0 0 5 0"
Content="Send"
i:Attached.Icon="fa-paper-plane"
Command="{Binding SendCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Grid.Column="2"
Height="33"
Width="33"
Click="OnFileUploadClicked"
i:Attached.Icon="fa-paperclip"
IsEnabled="{Binding IsConnected}" />
</Grid> </Grid>
</UserControl> </UserControl>
+35 -9
View File
@@ -4,6 +4,7 @@ using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Decho.Models;
using Decho.ViewModels; using Decho.ViewModels;
using System.Globalization; using System.Globalization;
@@ -134,15 +135,38 @@ public partial class MessageComposerView : UserControl
IReadOnlyList<IStorageFile> files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions IReadOnlyList<IStorageFile> files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
AllowMultiple = false, AllowMultiple = true,
Title = "Select a file to upload", Title = "Select files to attach",
}); });
IStorageFile? file = files?.FirstOrDefault(); List<string> paths = [];
if (file?.TryGetLocalPath() is string path) 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) private void OnDragOver(object? sender, DragEventArgs e)
@@ -164,14 +188,16 @@ public partial class MessageComposerView : UserControl
} }
#pragma warning disable CS0618 #pragma warning disable CS0618
string? paths = e.Data.GetFiles()? List<string> paths = e.Data.GetFiles()?
.Select(f => f.TryGetLocalPath()) .Select(f => f.TryGetLocalPath())
.FirstOrDefault(p => p is not null); .Where(p => p is not null)
.Cast<string>()
.ToList() ?? [];
#pragma warning restore CS0618 #pragma warning restore CS0618
if (paths is string path) if (paths.Count > 0)
{ {
vm.RequestFileUpload(path); vm.StageFiles(paths);
} }
} }
} }