mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: add image handling actions and improve IRC message formatting
This commit is contained in:
@@ -110,6 +110,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||
_mainWindow.OnImageOpenRequested += HandleImageOpenRequested;
|
||||
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||
@@ -1704,6 +1705,62 @@ public sealed class AppOrchestrator : IDisposable
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
/// <summary>File extensions the "[open]" action will hand to the OS image viewer for E2E rooms.</summary>
|
||||
private static readonly HashSet<string> ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Views an image without saving it to the user's downloads. Plain channels open the
|
||||
/// file's web URL in the default browser (the server serves files by capability URL, so
|
||||
/// no auth token is needed). E2E-encrypted channels would render as ciphertext in a
|
||||
/// browser, so the blob is downloaded, decrypted locally, and opened from a temp file.
|
||||
/// </summary>
|
||||
private void HandleImageOpenRequested(string attachmentUrl, string fileName)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _);
|
||||
|
||||
if (!isEncryptedRoom)
|
||||
{
|
||||
var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||
? attachmentUrl
|
||||
: $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}";
|
||||
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In E2E rooms the attachment kind is sender-declared, so only hand real image
|
||||
// extensions to the OS viewer; anything else goes through the save path instead.
|
||||
if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||
{
|
||||
HandleImageSaveRequested(attachmentUrl, fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}..."));
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
}, "Failed to open image");
|
||||
}
|
||||
|
||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
|
||||
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI.Chat;
|
||||
|
||||
/// <summary>An action a click on an attachment line can trigger.</summary>
|
||||
public enum AttachmentAction
|
||||
{
|
||||
OpenImage,
|
||||
SaveImage,
|
||||
}
|
||||
|
||||
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
|
||||
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
|
||||
|
||||
/// <summary>
|
||||
/// A single line in the chat, composed of colored segments.
|
||||
/// </summary>
|
||||
@@ -20,6 +30,14 @@ public partial class ChatLine
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public AttachmentKind? AttachmentKind { get; set; }
|
||||
public string? SenderUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
|
||||
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
|
||||
/// keeps them. Null means the whole line uses the kind's default action.
|
||||
/// </summary>
|
||||
public List<AttachmentActionSpan>? ActionSpans { get; set; }
|
||||
|
||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||
public int ContinuationIndent { get; set; }
|
||||
|
||||
@@ -160,6 +178,10 @@ public partial class ChatLine
|
||||
wrapped.IsMention = IsMention;
|
||||
}
|
||||
|
||||
// Span columns only line up with the first wrapped line; later lines fall
|
||||
// back to the kind's default action.
|
||||
results[0].ActionSpans = ActionSpans;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -443,9 +443,7 @@ public sealed class ChatMessageManager
|
||||
lines.Add(new ChatLine(segments));
|
||||
}
|
||||
}
|
||||
lines.Add(AttachmentActionLine(
|
||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
||||
ChatColors.FileAttr, attachment));
|
||||
lines.Add(ImageActionLine(attachment));
|
||||
break;
|
||||
|
||||
case Core.Models.AttachmentKind.Audio:
|
||||
@@ -489,6 +487,41 @@ public sealed class ChatMessageManager
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
|
||||
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
|
||||
/// keyboard activation (Enter) uses the default action, open.
|
||||
/// </summary>
|
||||
private static ChatLine ImageActionLine(AttachmentDto attachment)
|
||||
{
|
||||
var segments = RailPrefix();
|
||||
var col = segments.Sum(s => s.Text.GetColumns());
|
||||
var spans = new List<AttachmentActionSpan>();
|
||||
|
||||
void AddAction(string text, AttachmentAction action)
|
||||
{
|
||||
var width = text.GetColumns();
|
||||
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
|
||||
segments.Add(new(text, ChatColors.FileAttr));
|
||||
col += width;
|
||||
}
|
||||
|
||||
AddAction("[open]", AttachmentAction.OpenImage);
|
||||
segments.Add(new(" ", null));
|
||||
col += 1;
|
||||
AddAction("[↓ save original]", AttachmentAction.SaveImage);
|
||||
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
|
||||
|
||||
return new ChatLine(segments)
|
||||
{
|
||||
AttachmentUrl = attachment.Url,
|
||||
AttachmentFileName = attachment.FileName,
|
||||
AttachmentKind = attachment.Kind,
|
||||
ActionSpans = spans,
|
||||
ContinuationPrefixSegments = RailPrefix(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||
/// route activation (play audio, download file, save original image).
|
||||
|
||||
@@ -176,6 +176,12 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnImageSaveRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates an image's "[open]" action to view it without saving.
|
||||
/// Parameters: attachmentUrl, fileName.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnImageOpenRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||
/// </summary>
|
||||
@@ -532,7 +538,9 @@ public sealed partial class MainWindow : Runnable
|
||||
|
||||
if (line.AttachmentKind == AttachmentKind.Image)
|
||||
{
|
||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
// Keyboard/default activation opens the image for viewing;
|
||||
// saving is the mouse span or the context menu.
|
||||
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
@@ -600,7 +608,8 @@ public sealed partial class MainWindow : Runnable
|
||||
|
||||
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||
{
|
||||
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
|
||||
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||
return;
|
||||
|
||||
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||
@@ -610,6 +619,30 @@ public sealed partial class MainWindow : Runnable
|
||||
if (index < 0 || index >= source.Count)
|
||||
return;
|
||||
|
||||
// Left-click only activates the "[open]" / "[save original]" brackets on an
|
||||
// attachment action line; anywhere else it falls through to normal selection.
|
||||
if (leftClick)
|
||||
{
|
||||
var clicked = source.GetLine(index);
|
||||
if (clicked?.ActionSpans is { } spans
|
||||
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
|
||||
{
|
||||
foreach (var span in spans)
|
||||
{
|
||||
if (pos.X < span.StartCol || pos.X > span.EndCol)
|
||||
continue;
|
||||
|
||||
if (span.Action == AttachmentAction.OpenImage)
|
||||
OnImageOpenRequested?.Invoke(url, name);
|
||||
else
|
||||
OnImageSaveRequested?.Invoke(url, name);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||
_messageList.SelectedItem = index;
|
||||
_messageList.SetFocus();
|
||||
@@ -636,6 +669,7 @@ public sealed partial class MainWindow : Runnable
|
||||
switch (kind)
|
||||
{
|
||||
case AttachmentKind.Image:
|
||||
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
|
||||
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||
break;
|
||||
case AttachmentKind.Audio:
|
||||
|
||||
Reference in New Issue
Block a user