From 3a0ea0d321df9265c1e09f9a2ac5fe87512aca12 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 18:27:02 +0100 Subject: [PATCH] feat: enhance file upload and URL sending with optional size parameter --- src/EchoHub.Client/AppOrchestrator.cs | 6 +- src/EchoHub.Client/Commands/CommandHandler.cs | 28 +++++-- src/EchoHub.Client/Services/ApiClient.cs | 10 ++- src/EchoHub.Client/UI/ChatRenderer.cs | 82 ++++++++++++++++++- src/EchoHub.Client/UI/MainWindow.cs | 15 ++-- .../Controllers/ChannelsController.cs | 10 ++- .../Services/ImageToAsciiService.cs | 11 +++ 7 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index a0fd696..4e3474a 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -117,7 +117,7 @@ public sealed class AppOrchestrator : IDisposable return Task.CompletedTask; }; - _commandHandler.OnSendFile += async (target) => + _commandHandler.OnSendFile += async (target, size) => { if (!IsAuthenticated || !IsConnected) return; @@ -129,13 +129,13 @@ public sealed class AppOrchestrator : IDisposable if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { - await _apiClient!.SendUrlAsync(channel, target); + await _apiClient!.SendUrlAsync(channel, target, size); } else { await using var stream = File.OpenRead(target); var fileName = Path.GetFileName(target); - await _apiClient!.UploadFileAsync(channel, stream, fileName); + await _apiClient!.UploadFileAsync(channel, stream, fileName, size); } } catch (Exception ex) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 05596cc..c33d4d5 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -10,7 +10,7 @@ public class CommandHandler public event Func? OnSetNick; public event Func? OnSetColor; public event Func? OnSetTheme; - public event Func? OnSendFile; + public event Func? OnSendFile; public event Func? OnOpenProfile; public event Func? OnOpenServers; public event Func? OnJoinChannel; @@ -134,15 +134,31 @@ public class CommandHandler private async Task HandleSend(string args) { if (string.IsNullOrWhiteSpace(args)) - return new CommandResult(true, "Usage: /send ", IsError: true); + return new CommandResult(true, "Usage: /send [-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(); + + 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 [-s|-m|-l]", IsError: true); if (Uri.TryCreate(target, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https")) { if (OnSendFile is not null) - await OnSendFile(target); + await OnSendFile(target, size); var fileName = Path.GetFileName(uri.LocalPath); if (string.IsNullOrWhiteSpace(fileName)) fileName = "image"; @@ -153,7 +169,7 @@ public class CommandHandler return new CommandResult(true, $"File not found: {target}", IsError: true); if (OnSendFile is not null) - await OnSendFile(target); + await OnSendFile(target, size); return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); } @@ -326,7 +342,7 @@ public class CommandHandler /nick - Set display name /color <#hex> - Set nickname color /theme - Switch theme - /send - Send a file or image + /send [-s|-m|-l] - Send a file or image (size: small/medium/large) /avatar - Set your avatar /profile [username] - View a profile /servers - Open saved servers diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 64059f9..06f8249 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -160,7 +160,7 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName) + public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null) { EnsureAuthenticated(); using var content = new MultipartFormDataContent(); @@ -168,18 +168,20 @@ public sealed class ApiClient : IDisposable streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName)); content.Add(streamContent, "file", fileName); + var sizeQuery = size is not null ? $"?size={size}" : ""; 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); return await response.Content.ReadFromJsonAsync(); } - public async Task SendUrlAsync(string channelName, string url) + public async Task SendUrlAsync(string channelName, string url, string? size = null) { EnsureAuthenticated(); var request = new SendUrlRequest(url); + var sizeQuery = size is not null ? $"?size={size}" : ""; 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); return await response.Content.ReadFromJsonAsync(); } diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index b5bd92a..66681b8 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -214,7 +214,8 @@ public class ChatListSource : IListDataSource listView.Move(Math.Max(col - viewportX, 0), row); 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; int charPos = 0; @@ -368,6 +369,85 @@ public class ChannelListSource : IListDataSource public void Dispose() { } } +/// +/// Custom list data source for the online users panel with per-user nickname colors. +/// +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() { } +} + /// /// Shared color attributes for chat rendering (timestamps, system messages). /// diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 9fc8b14..98d85c2 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using System.Text.RegularExpressions; using EchoHub.Client.Themes; using EchoHub.Core.DTOs; @@ -30,6 +29,7 @@ public sealed class MainWindow : Runnable // Online users panel private readonly FrameView _usersFrame; private readonly ListView _usersList; + private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; private static readonly Key F2Key = Key.F2; @@ -215,7 +215,8 @@ public sealed class MainWindow : Runnable Width = Dim.Fill(), Height = Dim.Fill() }; - _usersList.SetSource(new ObservableCollection()); + _usersListSource = new UserListSource(); + _usersList.Source = _usersListSource; _usersFrame.Add(_usersList); Add(_usersFrame); @@ -679,7 +680,8 @@ public sealed class MainWindow : Runnable _chatFrame.Title = "Chat"; _topicLabel.Visible = false; _chatFrame.Y = 1; - _usersList.SetSource(new ObservableCollection()); + _usersListSource.Update([]); + _usersList.Source = _usersListSource; _usersFrame.Title = "Users"; RefreshMessages(); } @@ -805,10 +807,13 @@ public sealed class MainWindow : Runnable ServerRole.Mod => "\u2740", // ❀ _ => "" }; - return $"{statusIcon} {roleTag}{name}"; + var text = $"{statusIcon} {roleTag}{name}"; + var nameColor = ColorHelper.ParseHexColor(u.NicknameColor); + return (text, nameColor); }).ToList(); - _usersList.SetSource(new ObservableCollection(displayItems)); + _usersListSource.Update(displayItems); + _usersList.Source = _usersListSource; _usersFrame.Title = $"Users ({users.Count})"; } diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index af6bffb..eabef78 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -154,7 +154,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/upload")] [EnableRateLimiting("upload")] - public async Task Upload(string channel) + public async Task Upload(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -190,8 +190,9 @@ public class ChannelsController : ControllerBase if (isImage) { + var (w, h) = ImageToAsciiService.GetDimensions(size); using var imageStream = System.IO.File.OpenRead(filePath); - content = _asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream, w, h); } else { @@ -235,7 +236,7 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/send-url")] [EnableRateLimiting("upload")] - public async Task SendUrl(string channel, [FromBody] SendUrlRequest request) + public async Task SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var usernameClaim = User.FindFirstValue("username"); @@ -310,9 +311,10 @@ public class ChannelsController : ControllerBase var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName); string content; + var (w, h) = ImageToAsciiService.GetDimensions(size); using (var imageStream = System.IO.File.OpenRead(filePath)) { - content = _asciiService.ConvertToAscii(imageStream); + content = _asciiService.ConvertToAscii(imageStream, w, h); } var attachmentUrl = $"/api/files/{fileId}"; diff --git a/src/EchoHub.Server/Services/ImageToAsciiService.cs b/src/EchoHub.Server/Services/ImageToAsciiService.cs index 0b5d534..a354a29 100644 --- a/src/EchoHub.Server/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Server/Services/ImageToAsciiService.cs @@ -8,6 +8,17 @@ namespace EchoHub.Server.Services; public class ImageToAsciiService { + /// + /// Returns (width, height) dimensions for the given size code. + /// s = small (40x40), m = medium/default (80x80), l = large (120x120). + /// + public static (int Width, int Height) GetDimensions(string? size) => size?.ToLowerInvariant() switch + { + "s" => (40, 40), + "l" => (120, 120), + _ => (HubConstants.AsciiArtWidth, HubConstants.AsciiArtHeightHalfBlock), + }; + /// /// Converts an image to ASCII art using half-block characters (▀▄█) with /// 24-bit ANSI foreground and background colors for 2x vertical resolution.