mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 00:46: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);
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user