mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-08 15:56:11 +02:00
Add support for multiple file attachments in message composer
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Decho.Models;
|
||||||
|
|
||||||
|
public sealed record StagedFile(string FilePath, string FileName);
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
<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,*">
|
||||||
|
<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>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -65,4 +109,5 @@
|
|||||||
i:Attached.Icon="fa-paperclip"
|
i:Attached.Icon="fa-paperclip"
|
||||||
IsEnabled="{Binding IsConnected}" />
|
IsEnabled="{Binding IsConnected}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -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,17 +135,40 @@ 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)
|
||||||
{
|
{
|
||||||
#pragma warning disable CS0618
|
#pragma warning disable CS0618
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user