feat: add clipboard image handling for pasting and staging attachments

This commit is contained in:
HueByte
2026-07-16 19:40:18 +02:00
parent 31a0bb8c9f
commit 4f2cffa372
4 changed files with 462 additions and 16 deletions
+104 -8
View File
@@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _stagedAttachments = [];
// Temp PNGs created for clipboard-image pastes; deleted once their message is sent
// (or the staging tray is cleared) so pasted screenshots don't pile up in %TEMP%.
private readonly HashSet<string> _tempPastedFiles = [];
// E2E channels whose unlock prompt the user cancelled — don't nag on every reselect.
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
@@ -94,6 +98,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnDisconnectRequested += HandleDisconnect;
_mainWindow.OnLogoutRequested += HandleLogout;
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
_mainWindow.OnFilesStaged += HandleFilesStaged;
_mainWindow.OnImagePasted += HandleImagePasted;
_mainWindow.OnChannelSelected += HandleChannelSelected;
_mainWindow.OnProfileRequested += HandleProfileRequested;
_mainWindow.OnStatusRequested += HandleStatusRequested;
@@ -221,11 +227,89 @@ public sealed class AppOrchestrator : IDisposable
private Task HandleCmdClearAttachments()
{
var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList();
_tempPastedFiles.ExceptWith(temps);
CleanupPastedTempFiles(temps);
_stagedAttachments.Clear();
InvokeUI(RefreshStagingTray);
return Task.CompletedTask;
}
/// <summary>
/// Stages a batch of local files (multi-file paste or drag-and-drop) as attachments of the
/// next message. Runs synchronously on the UI thread — unlike routing each file through a
/// fire-and-forget /send command, a 10-file paste can't race the staging list.
/// </summary>
private void HandleFilesStaged(string channel, IReadOnlyList<string> files)
{
if (!_conn.IsAuthenticated || !_conn.IsConnected)
return;
var slotsLeft = HubConstants.MaxAttachmentsPerMessage - _stagedAttachments.Count;
_stagedAttachments.AddRange(files.Take(Math.Max(0, slotsLeft)));
if (files.Count > slotsLeft)
_mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.");
RefreshStagingTray();
}
/// <summary>
/// Stages an image pasted as raw clipboard data (copied from a browser, a screenshot tool,
/// or an image editor). The PNG is written to a per-paste temp folder so it flows through
/// the same path-based staging/encryption pipeline as regular files, and the temp file is
/// deleted once the message is sent.
/// </summary>
private void HandleImagePasted(string channel, byte[] png)
{
if (!_conn.IsAuthenticated || !_conn.IsConnected)
return;
if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage)
{
_mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message.");
return;
}
try
{
// A unique folder per paste keeps the Discord-style "image.png" display name
// while letting several pasted images coexist in one message.
var dir = Path.Combine(Path.GetTempPath(), "EchoHub", "pasted", Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "image.png");
File.WriteAllBytes(path, png);
_tempPastedFiles.Add(path);
_stagedAttachments.Add(path);
RefreshStagingTray();
}
catch (Exception ex)
{
Log.Error(ex, "Staging a pasted clipboard image failed");
_mainWindow.ShowError($"Pasting image failed: {ex.Message}");
}
}
/// <summary>Best-effort removal of pasted-image temp files and their per-paste folders.</summary>
private static void CleanupPastedTempFiles(IReadOnlyList<string> files)
{
foreach (var file in files)
{
try
{
File.Delete(file);
if (Path.GetDirectoryName(file) is { } dir)
Directory.Delete(dir);
}
catch (Exception ex)
{
Log.Debug(ex, "Could not delete pasted-image temp file {File}", file);
}
}
}
/// <summary>
/// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or
/// small/medium/large). The choice is a persistent preference applied to attached images.
@@ -307,17 +391,29 @@ public sealed class AppOrchestrator : IDisposable
_stagedAttachments.Clear();
InvokeUI(RefreshStagingTray);
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
// Pasted clipboard images live in temp files; once this send owns them they are
// deleted whether the upload succeeds or fails (the tray is already cleared).
var tempFiles = staged.Where(_tempPastedFiles.Contains).ToList();
_tempPastedFiles.ExceptWith(tempFiles);
var outgoing = new List<OutgoingAttachment>();
foreach (var path in staged)
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
try
{
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
? RoomCrypto.EncryptText(content, roomKey)
: content;
var outgoing = new List<OutgoingAttachment>();
foreach (var path in staged)
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
? RoomCrypto.EncryptText(content, roomKey)
: content;
await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size);
}
finally
{
CleanupPastedTempFiles(tempFiles);
}
}, "Send failed");
}