feat: enhance file upload and URL sending with optional size parameter

This commit is contained in:
HueByte
2026-02-19 18:27:02 +01:00
parent baebc093f5
commit 3a0ea0d321
7 changed files with 139 additions and 23 deletions
+3 -3
View File
@@ -117,7 +117,7 @@ public sealed class AppOrchestrator : IDisposable
return Task.CompletedTask; return Task.CompletedTask;
}; };
_commandHandler.OnSendFile += async (target) => _commandHandler.OnSendFile += async (target, size) =>
{ {
if (!IsAuthenticated || !IsConnected) return; if (!IsAuthenticated || !IsConnected) return;
@@ -129,13 +129,13 @@ public sealed class AppOrchestrator : IDisposable
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (uri.Scheme == "http" || uri.Scheme == "https"))
{ {
await _apiClient!.SendUrlAsync(channel, target); await _apiClient!.SendUrlAsync(channel, target, size);
} }
else else
{ {
await using var stream = File.OpenRead(target); await using var stream = File.OpenRead(target);
var fileName = Path.GetFileName(target); var fileName = Path.GetFileName(target);
await _apiClient!.UploadFileAsync(channel, stream, fileName); await _apiClient!.UploadFileAsync(channel, stream, fileName, size);
} }
} }
catch (Exception ex) catch (Exception ex)
+22 -6
View File
@@ -10,7 +10,7 @@ public class CommandHandler
public event Func<string, Task>? OnSetNick; public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor; public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme; public event Func<string, Task>? OnSetTheme;
public event Func<string, Task>? OnSendFile; public event Func<string, string?, Task>? OnSendFile;
public event Func<string?, Task>? OnOpenProfile; public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers; public event Func<Task>? OnOpenServers;
public event Func<string, Task>? OnJoinChannel; public event Func<string, Task>? OnJoinChannel;
@@ -134,15 +134,31 @@ public class CommandHandler
private async Task<CommandResult> HandleSend(string args) private async Task<CommandResult> HandleSend(string args)
{ {
if (string.IsNullOrWhiteSpace(args)) if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /send <filepath or URL>", IsError: true); return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
var target = args.Trim().Trim('"'); // Parse optional size flag (-s, -m, -l)
string? size = null;
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var targetParts = new List<string>();
foreach (var part in parts)
{
if (part is "-s" or "-m" or "-l")
size = part[1..]; // "s", "m", or "l"
else
targetParts.Add(part);
}
var target = string.Join(' ', targetParts).Trim('"');
if (string.IsNullOrWhiteSpace(target))
return new CommandResult(true, "Usage: /send <filepath or URL> [-s|-m|-l]", IsError: true);
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (uri.Scheme == "http" || uri.Scheme == "https"))
{ {
if (OnSendFile is not null) if (OnSendFile is not null)
await OnSendFile(target); await OnSendFile(target, size);
var fileName = Path.GetFileName(uri.LocalPath); var fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
fileName = "image"; fileName = "image";
@@ -153,7 +169,7 @@ public class CommandHandler
return new CommandResult(true, $"File not found: {target}", IsError: true); return new CommandResult(true, $"File not found: {target}", IsError: true);
if (OnSendFile is not null) if (OnSendFile is not null)
await OnSendFile(target); await OnSendFile(target, size);
return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}...");
} }
@@ -326,7 +342,7 @@ public class CommandHandler
/nick <name> - Set display name /nick <name> - Set display name
/color <#hex> - Set nickname color /color <#hex> - Set nickname color
/theme <name> - Switch theme /theme <name> - Switch theme
/send <filepath or URL> - Send a file or image /send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large)
/avatar <URL or filepath> - Set your avatar /avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile /profile [username] - View a profile
/servers - Open saved servers /servers - Open saved servers
+6 -4
View File
@@ -160,7 +160,7 @@ public sealed class ApiClient : IDisposable
return result?.AvatarAscii; return result?.AvatarAscii;
} }
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName) public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
using var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
@@ -168,18 +168,20 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName); content.Add(streamContent, "file", fileName);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload", content)); _http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<MessageDto?> SendUrlAsync(string channelName, string url) public async Task<MessageDto?> SendUrlAsync(string channelName, string url, string? size = null)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
var request = new SendUrlRequest(url); var request = new SendUrlRequest(url);
var sizeQuery = size is not null ? $"?size={size}" : "";
var response = await AuthenticatedRequestAsync(() => var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url", request)); _http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/send-url{sizeQuery}", request));
await EnsureSuccessAsync(response); await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
+81 -1
View File
@@ -214,7 +214,8 @@ public class ChatListSource : IListDataSource
listView.Move(Math.Max(col - viewportX, 0), row); listView.Move(Math.Max(col - viewportX, 0), row);
var chatLine = _lines[item]; var chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); // Always use Normal — chat messages should not show focus/selection highlight
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null; var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0; int charPos = 0;
@@ -368,6 +369,85 @@ public class ChannelListSource : IListDataSource
public void Dispose() { } public void Dispose() { }
} }
/// <summary>
/// Custom list data source for the online users panel with per-user nickname colors.
/// </summary>
public class UserListSource : IListDataSource
{
private readonly List<(string Text, Attribute? NameColor)> _users = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _users.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Update(List<(string Text, Attribute? NameColor)> users)
{
_users.Clear();
_users.AddRange(users);
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.Length) : 0;
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _users.Select(u => u.Text).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var (text, nameColor) = _users[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
// Find where the name starts (after status icon + space + optional role badge)
// Format: "● ★Username" or "● Username"
int nameStart = 0;
int i = 0;
// Skip status icon
while (i < text.Length && !char.IsLetterOrDigit(text[i]) && text[i] != '_') i++;
nameStart = i;
int drawnChars = 0;
// Draw prefix (status icon + role badge) in normal color
var prefixAttr = normalAttr;
for (int c = 0; c < nameStart && c < text.Length; c++)
{
if (drawnChars < width)
{
listView.SetAttribute(prefixAttr);
listView.AddRune(new Rune(text[c]));
drawnChars++;
}
}
// Draw name in nickname color
var userAttr = nameColor ?? normalAttr;
if (selected) userAttr = normalAttr; // use focus attr when selected
for (int c = nameStart; c < text.Length; c++)
{
if (drawnChars < width)
{
listView.SetAttribute(userAttr);
listView.AddRune(new Rune(text[c]));
drawnChars++;
}
}
// Fill rest
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
public void Dispose() { }
}
/// <summary> /// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages). /// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary> /// </summary>
+10 -5
View File
@@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
@@ -30,6 +29,7 @@ public sealed class MainWindow : Runnable
// Online users panel // Online users panel
private readonly FrameView _usersFrame; private readonly FrameView _usersFrame;
private readonly ListView _usersList; private readonly ListView _usersList;
private readonly UserListSource _usersListSource;
private bool _usersPanelVisible = true; private bool _usersPanelVisible = true;
private const int UsersPanelWidth = 22; private const int UsersPanelWidth = 22;
private static readonly Key F2Key = Key.F2; private static readonly Key F2Key = Key.F2;
@@ -215,7 +215,8 @@ public sealed class MainWindow : Runnable
Width = Dim.Fill(), Width = Dim.Fill(),
Height = Dim.Fill() Height = Dim.Fill()
}; };
_usersList.SetSource(new ObservableCollection<string>()); _usersListSource = new UserListSource();
_usersList.Source = _usersListSource;
_usersFrame.Add(_usersList); _usersFrame.Add(_usersList);
Add(_usersFrame); Add(_usersFrame);
@@ -679,7 +680,8 @@ public sealed class MainWindow : Runnable
_chatFrame.Title = "Chat"; _chatFrame.Title = "Chat";
_topicLabel.Visible = false; _topicLabel.Visible = false;
_chatFrame.Y = 1; _chatFrame.Y = 1;
_usersList.SetSource(new ObservableCollection<string>()); _usersListSource.Update([]);
_usersList.Source = _usersListSource;
_usersFrame.Title = "Users"; _usersFrame.Title = "Users";
RefreshMessages(); RefreshMessages();
} }
@@ -805,10 +807,13 @@ public sealed class MainWindow : Runnable
ServerRole.Mod => "\u2740", // ❀ ServerRole.Mod => "\u2740", // ❀
_ => "" _ => ""
}; };
return $"{statusIcon} {roleTag}{name}"; var text = $"{statusIcon} {roleTag}{name}";
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
return (text, nameColor);
}).ToList(); }).ToList();
_usersList.SetSource(new ObservableCollection<string>(displayItems)); _usersListSource.Update(displayItems);
_usersList.Source = _usersListSource;
_usersFrame.Title = $"Users ({users.Count})"; _usersFrame.Title = $"Users ({users.Count})";
} }
@@ -154,7 +154,7 @@ public class ChannelsController : ControllerBase
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> Upload(string channel) public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
@@ -190,8 +190,9 @@ public class ChannelsController : ControllerBase
if (isImage) if (isImage)
{ {
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath); using var imageStream = System.IO.File.OpenRead(filePath);
content = _asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream, w, h);
} }
else else
{ {
@@ -235,7 +236,7 @@ public class ChannelsController : ControllerBase
[HttpPost("{channel}/send-url")] [HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request) public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
@@ -310,9 +311,10 @@ public class ChannelsController : ControllerBase
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
string content; string content;
var (w, h) = ImageToAsciiService.GetDimensions(size);
using (var imageStream = System.IO.File.OpenRead(filePath)) using (var imageStream = System.IO.File.OpenRead(filePath))
{ {
content = _asciiService.ConvertToAscii(imageStream); content = _asciiService.ConvertToAscii(imageStream, w, h);
} }
var attachmentUrl = $"/api/files/{fileId}"; var attachmentUrl = $"/api/files/{fileId}";
@@ -8,6 +8,17 @@ namespace EchoHub.Server.Services;
public class ImageToAsciiService public class ImageToAsciiService
{ {
/// <summary>
/// Returns (width, height) dimensions for the given size code.
/// s = small (40x40), m = medium/default (80x80), l = large (120x120).
/// </summary>
public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch
{
"s" => (40, 40),
"l" => (120, 120),
_ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock),
};
/// <summary> /// <summary>
/// Converts an image to ASCII art using half-block characters (▀▄█) with /// Converts an image to ASCII art using half-block characters (▀▄█) with
/// 24-bit ANSI foreground and background colors for 2x vertical resolution. /// 24-bit ANSI foreground and background colors for 2x vertical resolution.