mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
Merge pull request #57 from HueByte/dev_copy_paste_qol
Dev copy paste qol
This commit is contained in:
@@ -4,7 +4,7 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.14](v0.2.14.md) - E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
||||
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
||||
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# v0.2.14
|
||||
|
||||
A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators.
|
||||
A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Ctrl+V grows up too — images copied from a browser or screenshot tool paste straight into the chat as attachments, and copying several files pastes them all into one message. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators.
|
||||
|
||||
## New Features
|
||||
|
||||
- **Paste images straight from the clipboard** — copy an image from a browser, a screenshot tool (Win+Shift+S), or an image editor and Ctrl+V it into the input: it's attached as a PNG (`image.png`), no saving to disk first, Discord-style. Transparency is preserved when the source provides PNG data; plain clipboard bitmaps are converted automatically. On Linux this uses `wl-paste`/`xclip`; on macOS it requires `pngpaste`. In end-to-end encrypted rooms pasted images go through the same client-side encryption as any other attachment.
|
||||
- **Multi-file paste** — copying several files in your file manager and pasting attaches them all to a single message (up to the 10-attachment cap), staged as one batch alongside anything you type as the caption. Previously each pasted file was routed through its own `/send`, which could misbehave on large batches.
|
||||
- **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form.
|
||||
- **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged.
|
||||
- **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using Serilog;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw image data from the OS clipboard (e.g. an image copied from a browser, or a
|
||||
/// Win+Shift+S screenshot), which terminals cannot paste as text. Always returns PNG bytes:
|
||||
/// clipboard PNG data is passed through, clipboard bitmaps (CF_DIB) are re-encoded.
|
||||
/// </summary>
|
||||
public static class ClipboardImage
|
||||
{
|
||||
private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47];
|
||||
|
||||
public static bool TryGetPng(out byte[] png)
|
||||
{
|
||||
png = [];
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return TryGetWindows(out png);
|
||||
if (OperatingSystem.IsLinux())
|
||||
return TryGetLinux(out png);
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return TryGetMacOS(out png);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Reading an image from the clipboard failed");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPng(byte[] data) =>
|
||||
data.Length > PngMagic.Length && data.AsSpan(0, PngMagic.Length).SequenceEqual(PngMagic);
|
||||
|
||||
/// <summary>
|
||||
/// Converts clipboard DIB bytes (a BITMAPINFOHEADER/V4/V5 + optional palette/masks + pixel
|
||||
/// data, i.e. a .bmp file without its 14-byte file header) to PNG. Returns null when the
|
||||
/// data is malformed or not decodable as a bitmap.
|
||||
/// </summary>
|
||||
public static byte[]? DibToPng(byte[] dib)
|
||||
{
|
||||
if (dib.Length < 40)
|
||||
return null;
|
||||
|
||||
var headerSize = BitConverter.ToInt32(dib, 0);
|
||||
if (headerSize < 40 || headerSize > dib.Length)
|
||||
return null;
|
||||
|
||||
var bitCount = BitConverter.ToUInt16(dib, 14);
|
||||
var compression = BitConverter.ToUInt32(dib, 16);
|
||||
var clrUsed = BitConverter.ToUInt32(dib, 32);
|
||||
|
||||
// Pixel data offset: file header + info header + color masks + palette.
|
||||
// BI_BITFIELDS masks follow a plain 40-byte header; larger headers embed them.
|
||||
var maskBytes = headerSize == 40 && compression == 3 ? 12
|
||||
: headerSize == 40 && compression == 6 ? 16
|
||||
: 0;
|
||||
var paletteEntries = clrUsed != 0 ? clrUsed
|
||||
: bitCount <= 8 ? 1u << bitCount
|
||||
: 0u;
|
||||
var pixelOffset = (uint)(14 + headerSize + maskBytes) + paletteEntries * 4;
|
||||
|
||||
var bmp = new byte[14 + dib.Length];
|
||||
bmp[0] = (byte)'B';
|
||||
bmp[1] = (byte)'M';
|
||||
BitConverter.TryWriteBytes(bmp.AsSpan(2), (uint)bmp.Length);
|
||||
BitConverter.TryWriteBytes(bmp.AsSpan(10), pixelOffset);
|
||||
dib.CopyTo(bmp, 14);
|
||||
|
||||
try
|
||||
{
|
||||
using var image = Image.Load(bmp);
|
||||
using var ms = new MemoryStream();
|
||||
image.SaveAsPng(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
catch (Exception ex) when (ex is ImageFormatException or InvalidOperationException)
|
||||
{
|
||||
Log.Warning(ex, "Clipboard DIB could not be decoded as a bitmap");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Windows: "PNG" / "image/png" registered formats, then CF_DIB ─────────
|
||||
|
||||
private const uint CfDib = 8;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static bool TryGetWindows(out byte[] png)
|
||||
{
|
||||
png = [];
|
||||
|
||||
// Browsers register a "PNG" (Chromium) or "image/png" (some apps) clipboard format
|
||||
// preserving transparency; CF_DIB is synthesized by Windows for everything else
|
||||
// (screenshots, image editors), so together these cover all image sources.
|
||||
var pngFormat = RegisterClipboardFormatW("PNG");
|
||||
var mimeFormat = RegisterClipboardFormatW("image/png");
|
||||
|
||||
var hasAny = (pngFormat != 0 && IsClipboardFormatAvailable(pngFormat))
|
||||
|| (mimeFormat != 0 && IsClipboardFormatAvailable(mimeFormat))
|
||||
|| IsClipboardFormatAvailable(CfDib);
|
||||
if (!hasAny)
|
||||
return false;
|
||||
|
||||
var opened = false;
|
||||
for (var attempt = 0; attempt < 5 && !opened; attempt++)
|
||||
opened = OpenClipboard(IntPtr.Zero);
|
||||
if (!opened)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var format in new[] { pngFormat, mimeFormat })
|
||||
{
|
||||
if (format == 0 || !IsClipboardFormatAvailable(format))
|
||||
continue;
|
||||
var data = ReadHGlobal(GetClipboardData(format));
|
||||
if (data is not null && IsPng(data))
|
||||
{
|
||||
png = data;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (IsClipboardFormatAvailable(CfDib)
|
||||
&& ReadHGlobal(GetClipboardData(CfDib)) is { } dib
|
||||
&& DibToPng(dib) is { } converted)
|
||||
{
|
||||
png = converted;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseClipboard();
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static byte[]? ReadHGlobal(IntPtr handle)
|
||||
{
|
||||
if (handle == IntPtr.Zero)
|
||||
return null;
|
||||
|
||||
var ptr = GlobalLock(handle);
|
||||
if (ptr == IntPtr.Zero)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var size = (int)GlobalSize(handle);
|
||||
if (size <= 0)
|
||||
return null;
|
||||
var data = new byte[size];
|
||||
Marshal.Copy(ptr, data, 0, size);
|
||||
return data;
|
||||
}
|
||||
finally
|
||||
{
|
||||
GlobalUnlock(handle);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool OpenClipboard(IntPtr hWndNewOwner);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseClipboard();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool IsClipboardFormatAvailable(uint format);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GetClipboardData(uint uFormat);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint RegisterClipboardFormatW(string lpszFormat);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr GlobalLock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalUnlock(IntPtr hMem);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nuint GlobalSize(IntPtr hMem);
|
||||
|
||||
// ── Linux: image/png via wl-paste or xclip ────────────────────────────────
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
private static bool TryGetLinux(out byte[] png)
|
||||
{
|
||||
png = [];
|
||||
var data = RunForBytes("wl-paste", ["--type", "image/png"])
|
||||
?? RunForBytes("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]);
|
||||
if (data is null || !IsPng(data))
|
||||
return false;
|
||||
|
||||
png = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── macOS: pngpaste (brew install pngpaste), when present ────────────────
|
||||
|
||||
[SupportedOSPlatform("macos")]
|
||||
private static bool TryGetMacOS(out byte[] png)
|
||||
{
|
||||
png = [];
|
||||
var data = RunForBytes("pngpaste", ["-"]);
|
||||
if (data is null || !IsPng(data))
|
||||
return false;
|
||||
|
||||
png = data;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[]? RunForBytes(string fileName, IEnumerable<string> args)
|
||||
{
|
||||
var psi = new ProcessStartInfo(fileName)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
return null;
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
process.StandardOutput.BaseStream.CopyTo(ms);
|
||||
process.WaitForExit(2000);
|
||||
return process.ExitCode == 0 && ms.Length > 0 ? ms.ToArray() : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException)
|
||||
{
|
||||
return null; // tool not installed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using Serilog;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Configuration;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Drivers;
|
||||
using Terminal.Gui.Input;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.ViewBase;
|
||||
@@ -41,23 +42,24 @@ public sealed partial class MainWindow : Runnable
|
||||
private bool _usersPanelVisible = true;
|
||||
private const int UsersPanelWidth = 22;
|
||||
private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message";
|
||||
private static readonly Key F2Key = Key.F2;
|
||||
private const KeyCode F2Key = KeyCode.F2;
|
||||
private bool _hasStagedAttachments;
|
||||
|
||||
internal static readonly string AppVersion =
|
||||
typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?";
|
||||
|
||||
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
|
||||
private static readonly Key EnterKey = Key.Enter;
|
||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||
private static readonly Key TabKey = Key.Tab;
|
||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||
private static readonly Key CtrlVKey = Key.V.WithCtrl;
|
||||
private static readonly Key CtrlXKey = Key.X.WithCtrl;
|
||||
private static readonly Key CtrlCKey = Key.C.WithCtrl;
|
||||
private static readonly Key CtrlYKey = Key.Y.WithCtrl;
|
||||
private static readonly Key F6Key = Key.F6;
|
||||
// Key bindings as KeyCode constants: comparing raw KeyCodes avoids Key.Equals (which also
|
||||
// checks Handled), and constants make them usable as switch case labels.
|
||||
private const KeyCode EnterKey = KeyCode.Enter;
|
||||
private const KeyCode NewlineKey = KeyCode.N | KeyCode.CtrlMask;
|
||||
private const KeyCode AltQKey = KeyCode.Q | KeyCode.AltMask;
|
||||
private const KeyCode TabKey = KeyCode.Tab;
|
||||
private const KeyCode CtrlKKey = KeyCode.K | KeyCode.CtrlMask;
|
||||
private const KeyCode CtrlVKey = KeyCode.V | KeyCode.CtrlMask;
|
||||
private const KeyCode CtrlXKey = KeyCode.X | KeyCode.CtrlMask;
|
||||
private const KeyCode CtrlCKey = KeyCode.C | KeyCode.CtrlMask;
|
||||
private const KeyCode CtrlYKey = KeyCode.Y | KeyCode.CtrlMask;
|
||||
private const KeyCode F6Key = KeyCode.F6;
|
||||
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
@@ -87,6 +89,18 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnMessageSubmitted;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when local files arrive via paste or drag-and-drop to be staged as attachments.
|
||||
/// Parameters: channel name, absolute paths of existing files.
|
||||
/// </summary>
|
||||
public event Action<string, IReadOnlyList<string>>? OnFilesStaged;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a
|
||||
/// screenshot tool). Parameters: channel name, PNG-encoded image bytes.
|
||||
/// </summary>
|
||||
public event Action<string, byte[]>? OnImagePasted;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to connect via the menu.
|
||||
/// </summary>
|
||||
@@ -557,7 +571,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private void OnMessageListKeyDown(object? sender, Key e)
|
||||
{
|
||||
// F6 returns focus to the input box.
|
||||
if (e.KeyCode == F6Key.KeyCode)
|
||||
if (e.KeyCode == F6Key)
|
||||
{
|
||||
_inputField.SetFocus();
|
||||
e.Handled = true;
|
||||
@@ -703,66 +717,69 @@ public sealed partial class MainWindow : Runnable
|
||||
|
||||
private void OnInputKeyDown(object? sender, Key e)
|
||||
{
|
||||
if (e.KeyCode == TabKey.KeyCode)
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
TryAutocompleteCommand();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == NewlineKey.KeyCode)
|
||||
{
|
||||
_inputField.InsertText("\n");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == EnterKey.KeyCode)
|
||||
{
|
||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||
// Send when there's text, or when only attachments are staged (empty caption).
|
||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == AltQKey.KeyCode)
|
||||
{
|
||||
_app.RequestStop();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == F6Key.KeyCode)
|
||||
{
|
||||
// Move focus into the message list so you can select a message (arrows) and
|
||||
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
||||
FocusMessageList();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
||||
{
|
||||
// If a file was copied in the OS file manager, the clipboard holds a file list
|
||||
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
||||
// Windows Terminal, which never pastes copied files as text.
|
||||
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||
StageFiles(pastedFiles);
|
||||
else
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlXKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlCKey.KeyCode)
|
||||
{
|
||||
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||
e.Handled = true;
|
||||
case TabKey:
|
||||
TryAutocompleteCommand();
|
||||
break;
|
||||
|
||||
case NewlineKey:
|
||||
_inputField.InsertText("\n");
|
||||
break;
|
||||
|
||||
case EnterKey:
|
||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||
// Send when there's text, or when only attachments are staged (empty caption).
|
||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||
&& !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||
{
|
||||
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case AltQKey:
|
||||
_app.RequestStop();
|
||||
break;
|
||||
|
||||
case CtrlKKey:
|
||||
ShowSearchDialog();
|
||||
break;
|
||||
|
||||
case F6Key:
|
||||
// Move focus into the message list so you can select a message (arrows) and
|
||||
// delete it (Delete). F6 again returns focus here. (Esc is the app quit key.)
|
||||
FocusMessageList();
|
||||
break;
|
||||
|
||||
case CtrlVKey:
|
||||
case CtrlYKey:
|
||||
// Discord-style paste priority. Copied files in the OS file manager put a file
|
||||
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
||||
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
|
||||
// Terminals never deliver either of the first two as text, so this is the only path.
|
||||
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||
StageFiles(pastedFiles);
|
||||
else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel)
|
||||
&& ClipboardImage.TryGetPng(out var pastedPng))
|
||||
OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng);
|
||||
else
|
||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||
break;
|
||||
|
||||
case CtrlXKey:
|
||||
GuardedClipboardAction(() => _inputField.Cut(), "cut");
|
||||
break;
|
||||
|
||||
case CtrlCKey:
|
||||
GuardedClipboardAction(() => _inputField.Copy(), "copy");
|
||||
break;
|
||||
|
||||
default:
|
||||
return; // not one of ours — leave e.Handled false so the key types normally
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -872,17 +889,16 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
||||
/// stages them; the next Enter sends them with any typed caption.
|
||||
/// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the
|
||||
/// next Enter sends them with any typed caption.
|
||||
/// </summary>
|
||||
private void StageFiles(IEnumerable<string> files)
|
||||
private void StageFiles(IReadOnlyList<string> files)
|
||||
{
|
||||
var channel = _messageManager.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel))
|
||||
return;
|
||||
|
||||
foreach (var file in files)
|
||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
||||
OnFilesStaged?.Invoke(channel, files);
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
@@ -898,21 +914,25 @@ public sealed partial class MainWindow : Runnable
|
||||
|
||||
private void OnWindowKeyDown(object? sender, Key e)
|
||||
{
|
||||
if (e.KeyCode == AltQKey.KeyCode)
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
_app.RequestStop();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == F2Key.KeyCode)
|
||||
{
|
||||
ToggleUsersPanel();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
case AltQKey:
|
||||
_app.RequestStop();
|
||||
break;
|
||||
|
||||
case F2Key:
|
||||
ToggleUsersPanel();
|
||||
break;
|
||||
|
||||
case CtrlKKey:
|
||||
ShowSearchDialog();
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ShowSearchDialog()
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Core.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Bmp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
public class ClipboardImageTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes an image as BMP and strips the 14-byte BITMAPFILEHEADER, producing exactly what
|
||||
/// the Windows clipboard hands out as CF_DIB.
|
||||
/// </summary>
|
||||
private static byte[] MakeDib(Image<Rgba32> image, BmpBitsPerPixel bpp)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
image.Save(ms, new BmpEncoder { BitsPerPixel = bpp });
|
||||
return ms.ToArray()[14..];
|
||||
}
|
||||
|
||||
private static Image<Rgba32> MakeTestImage()
|
||||
{
|
||||
var image = new Image<Rgba32>(4, 3);
|
||||
image[0, 0] = new Rgba32(255, 0, 0);
|
||||
image[3, 2] = new Rgba32(0, 0, 255);
|
||||
return image;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BmpBitsPerPixel.Pixel24)]
|
||||
[InlineData(BmpBitsPerPixel.Pixel32)]
|
||||
[InlineData(BmpBitsPerPixel.Pixel8)] // palette-based: exercises the palette offset math
|
||||
public void DibToPng_ConvertsDibToValidPng(BmpBitsPerPixel bpp)
|
||||
{
|
||||
using var original = MakeTestImage();
|
||||
var dib = MakeDib(original, bpp);
|
||||
|
||||
var png = ClipboardImage.DibToPng(dib);
|
||||
|
||||
Assert.NotNull(png);
|
||||
using var pngStream = new MemoryStream(png);
|
||||
Assert.True(FileValidationHelper.IsValidImage(pngStream));
|
||||
|
||||
using var decoded = Image.Load<Rgba32>(png);
|
||||
Assert.Equal(original.Width, decoded.Width);
|
||||
Assert.Equal(original.Height, decoded.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DibToPng_PreservesPixels_For24Bpp()
|
||||
{
|
||||
using var original = MakeTestImage();
|
||||
var dib = MakeDib(original, BmpBitsPerPixel.Pixel24);
|
||||
|
||||
var png = ClipboardImage.DibToPng(dib);
|
||||
|
||||
Assert.NotNull(png);
|
||||
using var decoded = Image.Load<Rgba32>(png);
|
||||
Assert.Equal(new Rgba32(255, 0, 0), decoded[0, 0]);
|
||||
Assert.Equal(new Rgba32(0, 0, 255), decoded[3, 2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DibToPng_ReturnsNull_ForTruncatedData()
|
||||
{
|
||||
Assert.Null(ClipboardImage.DibToPng([0x28, 0x00, 0x00]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DibToPng_ReturnsNull_ForGarbageData()
|
||||
{
|
||||
var garbage = new byte[256];
|
||||
new Random(42).NextBytes(garbage);
|
||||
Assert.Null(ClipboardImage.DibToPng(garbage));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user