diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 9d2544b..97b08d2 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -144,9 +144,54 @@ public sealed class AppOrchestrator : IDisposable } }; - _commandHandler.OnOpenProfile += () => + _commandHandler.OnSetAvatar += async (target) => { - InvokeUI(HandleProfileRequested); + if (!IsAuthenticated) return; + + try + { + Stream stream; + string fileName; + + if (Uri.TryCreate(target, UriKind.Absolute, out var uri) + && (uri.Scheme == "http" || uri.Scheme == "https")) + { + using var http = new HttpClient(); + var bytes = await http.GetByteArrayAsync(uri); + stream = new MemoryStream(bytes); + fileName = Path.GetFileName(uri.LocalPath); + if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) + fileName = "avatar.png"; + } + else + { + if (!File.Exists(target)) + { + InvokeUI(() => _mainWindow.ShowError($"File not found: {target}")); + return; + } + stream = File.OpenRead(target); + fileName = Path.GetFileName(target); + } + + await using (stream) + { + var ascii = await _apiClient!.UploadAvatarAsync(stream, fileName); + var channel = _mainWindow.CurrentChannel; + if (!string.IsNullOrEmpty(channel)) + InvokeUI(() => _mainWindow.AddSystemMessage(channel, "Avatar updated.")); + } + } + catch (Exception ex) + { + Log.Error(ex, "Avatar upload failed for {Target}", target); + InvokeUI(() => _mainWindow.ShowError($"Avatar upload failed: {ex.Message}")); + } + }; + + _commandHandler.OnOpenProfile += (username) => + { + InvokeUI(() => HandleViewProfile(username)); return Task.CompletedTask; }; @@ -400,35 +445,55 @@ public sealed class AppOrchestrator : IDisposable private void HandleProfileRequested() { + HandleViewProfile(null); + } + + private void HandleViewProfile(string? username) + { + // If no username or it's our own, show the full user panel + var isOwnProfile = string.IsNullOrWhiteSpace(username) + || username.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase); + Task.Run(async () => { UserProfileDto? profile = null; try { - if (IsAuthenticated && !string.IsNullOrEmpty(_currentUsername)) - profile = await _apiClient!.GetUserProfileAsync(_currentUsername); + if (IsAuthenticated) + { + var target = isOwnProfile ? _currentUsername : username!; + if (!string.IsNullOrEmpty(target)) + profile = await _apiClient!.GetUserProfileAsync(target); + } } - catch + catch (Exception ex) { - // Profile may not be available; continue with null + InvokeUI(() => _mainWindow.ShowError($"Failed to load profile: {ex.Message}")); + return; } InvokeUI(() => { - var action = UserPanelDialog.Show(_app, - profile, - _config.SavedServers, - _currentStatus, - _currentStatusMessage); - - switch (action) + if (isOwnProfile) { - case UserPanelAction.EditProfile: - HandleEditProfile(profile); - break; - case UserPanelAction.SetStatus: - HandleStatusRequested(); - break; + var action = ProfileViewDialog.ShowOwn(_app, + profile, + _currentStatus, + _currentStatusMessage); + + switch (action) + { + case ProfileAction.EditProfile: + HandleEditProfile(profile); + break; + case ProfileAction.SetStatus: + HandleStatusRequested(); + break; + } + } + else + { + ProfileViewDialog.Show(_app, profile); } }); }); diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 60c6abe..f552fec 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -11,12 +11,13 @@ public class CommandHandler public event Func? OnSetColor; public event Func? OnSetTheme; public event Func? OnSendFile; - public event Func? OnOpenProfile; + public event Func? OnOpenProfile; public event Func? OnOpenServers; public event Func? OnJoinChannel; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? OnListUsers; + public event Func? OnSetAvatar; public event Func? OnQuit; public event Func? OnHelp; @@ -38,7 +39,8 @@ public class CommandHandler "color" => await HandleColor(args), "theme" => await HandleTheme(args), "send" => await HandleSend(args), - "profile" => await HandleProfile(), + "profile" => await HandleProfile(args), + "avatar" => await HandleAvatar(args), "servers" => await HandleServers(), "join" => await HandleJoin(args), "leave" => await HandleLeave(), @@ -141,13 +143,26 @@ public class CommandHandler return new CommandResult(true, $"Uploading: {Path.GetFileName(target)}..."); } - private async Task HandleProfile() + private async Task HandleProfile(string args) { + var username = string.IsNullOrWhiteSpace(args) ? null : args.Trim(); if (OnOpenProfile is not null) - await OnOpenProfile(); + await OnOpenProfile(username); return new CommandResult(true); } + private async Task HandleAvatar(string args) + { + if (string.IsNullOrWhiteSpace(args)) + return new CommandResult(true, "Usage: /avatar ", IsError: true); + + var target = args.Trim().Trim('"'); + + if (OnSetAvatar is not null) + await OnSetAvatar(target); + return new CommandResult(true, "Uploading avatar..."); + } + private async Task HandleServers() { if (OnOpenServers is not null) @@ -209,7 +224,8 @@ public class CommandHandler /color <#hex> - Set nickname color /theme - Switch theme /send - Send a file or image - /profile - Open your profile + /avatar - Set your avatar + /profile [username] - View a profile (yours if no name given) /servers - Open saved servers /join - Join a channel /leave - Leave current channel diff --git a/src/EchoHub.Client/EchoHub.Client.csproj b/src/EchoHub.Client/EchoHub.Client.csproj index b81b1dd..77322e1 100644 --- a/src/EchoHub.Client/EchoHub.Client.csproj +++ b/src/EchoHub.Client/EchoHub.Client.csproj @@ -14,11 +14,11 @@ - + PreserveNewest - - EchoHub.Client.appsettings.json + + EchoHub.Client.appsettings.example.json diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 608f310..7a6e225 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -9,7 +9,7 @@ var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json") if (!File.Exists(appSettingsPath)) { using var stream = typeof(AppOrchestrator).Assembly - .GetManifestResourceStream("EchoHub.Client.appsettings.json"); + .GetManifestResourceStream("EchoHub.Client.appsettings.example.json"); if (stream is not null) { diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index a385f4b..77a953e 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -27,7 +27,7 @@ public sealed class MainWindow : Runnable // 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 AltEnterKey = Key.Enter.WithAlt; + private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key CtrlCKey = Key.C.WithCtrl; private static readonly Key TabKey = Key.Tab; @@ -35,8 +35,8 @@ public sealed class MainWindow : Runnable private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", - "/profile", "/servers", "/join", "/leave", "/topic", - "/users", "/quit", "/help" + "/avatar", "/profile", "/servers", "/join", "/leave", + "/topic", "/users", "/quit", "/help" ]; private readonly List _channelNames = []; @@ -159,7 +159,7 @@ public sealed class MainWindow : Runnable // Bottom input area var inputFrame = new FrameView { - Title = "Message (Enter=send, Tab=autocomplete)", + Title = "Message (Enter=send, Ctrl+N=newline, Tab=autocomplete)", X = 25, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(), @@ -194,7 +194,9 @@ public sealed class MainWindow : Runnable ApplyColorSchemes(); // Re-wrap messages when the chat area is resized + // Subscribe to both ListView and FrameView viewport changes for reliable resize detection _messageList.ViewportChanged += (_, _) => OnChatViewportChanged(); + _chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged(); // Window-level key handling for Ctrl+C (quit) KeyDown += OnWindowKeyDown; @@ -316,7 +318,7 @@ public sealed class MainWindow : Runnable TryAutocompleteCommand(); e.Handled = true; } - else if (e.KeyCode == AltEnterKey.KeyCode) + else if (e.KeyCode == NewlineKey.KeyCode) { _inputField.InsertText("\n"); e.Handled = true; @@ -603,6 +605,14 @@ public sealed class MainWindow : Runnable if (_channelMessages.TryGetValue(_currentChannel, out var messages)) { var width = _messageList.Viewport.Width; + + // Update cached width when viewport reports a valid value; + // fall back to last known width if viewport hasn't been laid out yet. + if (width > 0) + _lastChatWidth = width; + else + width = _lastChatWidth; + var source = new ChatListSource(); if (width > 0) diff --git a/src/EchoHub.Client/UI/ProfileViewDialog.cs b/src/EchoHub.Client/UI/ProfileViewDialog.cs new file mode 100644 index 0000000..db421fa --- /dev/null +++ b/src/EchoHub.Client/UI/ProfileViewDialog.cs @@ -0,0 +1,246 @@ +using Terminal.Gui.App; +using Terminal.Gui.Views; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Drawing; +using EchoHub.Core.DTOs; +using EchoHub.Core.Models; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI; + +/// +/// Action selected by the user in their own profile dialog. +/// +public enum ProfileAction +{ + Close, + EditProfile, + SetStatus +} + +/// +/// Dialog for viewing a user's server profile. +/// Shows Edit Profile / Set Status buttons when viewing own profile. +/// +public sealed class ProfileViewDialog +{ + /// + /// Show a read-only profile view for another user. + /// + public static void Show(IApplication app, UserProfileDto? profile) + { + ShowInternal(app, profile, isOwnProfile: false); + } + + /// + /// Show the profile view for the current user with action buttons. + /// Returns the action the user selected. + /// + public static ProfileAction ShowOwn( + IApplication app, + UserProfileDto? profile, + UserStatus currentStatus, + string? currentStatusMessage) + { + return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage); + } + + private static ProfileAction ShowInternal( + IApplication app, + UserProfileDto? profile, + bool isOwnProfile, + UserStatus? currentStatus = null, + string? currentStatusMessage = null) + { + if (profile is null) + { + MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK"); + return ProfileAction.Close; + } + + var action = ProfileAction.Close; + + var dialog = new Dialog + { + Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}", + Width = 50, + Height = 20 + }; + + int row = 0; + + // Username + var usernameLabel = new Label { Text = "Username:", X = 1, Y = row }; + var usernameValue = new Label { Text = profile.Username, X = 14, Y = row }; + usernameValue.SetScheme(new Scheme + { + Normal = new Attribute(Color.BrightYellow, Color.Blue) + }); + dialog.Add(usernameLabel, usernameValue); + row++; + + // Display Name + var nameLabel = new Label { Text = "Name:", X = 1, Y = row }; + var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row }; + dialog.Add(nameLabel, nameValue); + row++; + + // Status — use live status for own profile, stored status for others + var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status; + var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage; + + var statusLabel = new Label { Text = "Status:", X = 1, Y = row }; + var statusText = FormatStatus(displayStatus); + var statusValue = new Label { Text = statusText, X = 14, Y = row }; + statusValue.SetScheme(new Scheme + { + Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue) + }); + dialog.Add(statusLabel, statusValue); + row++; + + // Status Message + if (!string.IsNullOrWhiteSpace(displayStatusMsg)) + { + var msgLabel = new Label { Text = "Message:", X = 1, Y = row }; + var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) }; + dialog.Add(msgLabel, msgValue); + row++; + } + + // Color + var colorLabel = new Label { Text = "Color:", X = 1, Y = row }; + var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row }; + if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr) + colorValue.SetScheme(new Scheme { Normal = colorAttr }); + dialog.Add(colorLabel, colorValue); + row++; + + // Bio + row++; + var bioLabel = new Label { Text = "Bio:", X = 1, Y = row }; + dialog.Add(bioLabel); + row++; + + var bioView = new TextView + { + X = 1, + Y = row, + Width = Dim.Fill(2), + Height = 3, + Text = profile.Bio ?? "-", + ReadOnly = true, + WordWrap = true + }; + bioView.SetScheme(new Scheme + { + Normal = new Attribute(Color.White, Color.DarkGray), + Focus = new Attribute(Color.White, Color.DarkGray) + }); + dialog.Add(bioView); + row += 3; + + // ASCII Avatar + if (!string.IsNullOrWhiteSpace(profile.AvatarAscii)) + { + row++; + var avatarLines = profile.AvatarAscii.Split('\n').Length; + var avatarHeight = Math.Min(avatarLines + 2, 6); + var avatarFrame = new FrameView + { + Title = "Avatar", + X = 1, + Y = row, + Width = Dim.Fill(2), + Height = avatarHeight + }; + avatarFrame.Add(new Label { Text = profile.AvatarAscii, X = 0, Y = 0 }); + dialog.Add(avatarFrame); + // Grow dialog to fit avatar + dialog.Height = row + avatarHeight + 4; + } + + // Buttons + if (isOwnProfile) + { + var editButton = new Button + { + Text = "Edit Profile", + X = Pos.Center() - 20, + Y = Pos.AnchorEnd(2) + }; + editButton.Accepting += (s, e) => + { + action = ProfileAction.EditProfile; + e.Handled = true; + app.RequestStop(); + }; + + var statusButton = new Button + { + Text = "Set Status", + X = Pos.Center() - 4, + Y = Pos.AnchorEnd(2) + }; + statusButton.Accepting += (s, e) => + { + action = ProfileAction.SetStatus; + e.Handled = true; + app.RequestStop(); + }; + + var closeButton = new Button + { + Text = "Close", + IsDefault = true, + X = Pos.Center() + 13, + Y = Pos.AnchorEnd(2) + }; + closeButton.Accepting += (s, e) => + { + action = ProfileAction.Close; + e.Handled = true; + app.RequestStop(); + }; + + dialog.Add(editButton, statusButton, closeButton); + } + else + { + var closeButton = new Button + { + Text = "Close", + IsDefault = true, + X = Pos.Center(), + Y = Pos.AnchorEnd(2) + }; + closeButton.Accepting += (s, e) => + { + e.Handled = true; + app.RequestStop(); + }; + dialog.Add(closeButton); + } + + app.Run(dialog); + return action; + } + + private static string FormatStatus(UserStatus status) => status switch + { + UserStatus.Online => "\u25cf Online", + UserStatus.Away => "\u25cf Away", + UserStatus.DoNotDisturb => "\u25cf Do Not Disturb", + UserStatus.Invisible => "\u25cb Invisible", + _ => "\u25cf Unknown" + }; + + private static Color GetStatusColor(UserStatus status) => status switch + { + UserStatus.Online => Color.BrightGreen, + UserStatus.Away => Color.BrightYellow, + UserStatus.DoNotDisturb => Color.BrightRed, + UserStatus.Invisible => Color.Gray, + _ => Color.White + }; +} diff --git a/src/EchoHub.Client/UI/UserPanelDialog.cs b/src/EchoHub.Client/UI/UserPanelDialog.cs deleted file mode 100644 index b82e357..0000000 --- a/src/EchoHub.Client/UI/UserPanelDialog.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System.Collections.ObjectModel; -using Terminal.Gui.App; -using Terminal.Gui.Views; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Drawing; -using EchoHub.Core.DTOs; -using EchoHub.Core.Models; -using EchoHub.Client.Config; -using Attribute = Terminal.Gui.Drawing.Attribute; - -namespace EchoHub.Client.UI; - -/// -/// Action selected by the user in the user panel dialog. -/// -public enum UserPanelAction -{ - Close, - EditProfile, - SetStatus -} - -/// -/// A Terminal.Gui dialog for viewing the user panel -- profile info, saved servers, and status. -/// -public sealed class UserPanelDialog -{ - /// - /// Shows the user panel dialog and returns the action the user selected. - /// - public static UserPanelAction Show( - IApplication app, - UserProfileDto? profile, - List savedServers, - UserStatus currentStatus, - string? currentStatusMessage) - { - var action = UserPanelAction.Close; - - var dialog = new Dialog { Title = "User Panel", Width = 70, Height = 24 }; - - // -- Left side: Profile info ------------------------------------------ - var profileFrame = new FrameView - { - Title = "Profile", - X = 0, - Y = 0, - Width = 35, - Height = Dim.Fill(3) - }; - - int row = 0; - - // Username - var usernameLabel = new Label - { - Text = "Username:", - X = 1, - Y = row - }; - var usernameValue = new Label - { - Text = profile?.Username ?? "N/A", - X = 12, - Y = row - }; - usernameValue.SetScheme(new Scheme - { - Normal = new Attribute(Color.BrightYellow, Color.Blue) - }); - profileFrame.Add(usernameLabel, usernameValue); - row += 1; - - // Display Name - var displayLabel = new Label - { - Text = "Name:", - X = 1, - Y = row - }; - var displayValue = new Label - { - Text = profile?.DisplayName ?? "-", - X = 12, - Y = row - }; - profileFrame.Add(displayLabel, displayValue); - row += 1; - - // Status - var statusLabel = new Label - { - Text = "Status:", - X = 1, - Y = row - }; - var statusText = FormatStatus(currentStatus); - var statusValue = new Label - { - Text = statusText, - X = 12, - Y = row - }; - statusValue.SetScheme(new Scheme - { - Normal = new Attribute(GetStatusColor(currentStatus), Color.Blue) - }); - profileFrame.Add(statusLabel, statusValue); - row += 1; - - // Status Message - if (!string.IsNullOrWhiteSpace(currentStatusMessage)) - { - var msgLabel = new Label - { - Text = "Message:", - X = 1, - Y = row - }; - var msgValue = new Label - { - Text = Truncate(currentStatusMessage, 20), - X = 12, - Y = row - }; - profileFrame.Add(msgLabel, msgValue); - row += 1; - } - - // Bio - row += 1; - var bioLabel = new Label - { - Text = "Bio:", - X = 1, - Y = row - }; - profileFrame.Add(bioLabel); - row += 1; - - var bioText = profile?.Bio ?? "-"; - var bioView = new TextView() - { - X = 1, - Y = row, - Width = Dim.Fill(1), - Height = 3, - Text = bioText, - ReadOnly = true, - WordWrap = true - }; - bioView.SetScheme(new Scheme - { - Normal = new Attribute(Color.White, Color.DarkGray), - Focus = new Attribute(Color.White, Color.DarkGray) - }); - profileFrame.Add(bioView); - row += 3; - - // Color - var colorLabel = new Label - { - Text = "Color:", - X = 1, - Y = row - }; - var colorValue = new Label - { - Text = profile?.NicknameColor ?? "-", - X = 12, - Y = row - }; - profileFrame.Add(colorLabel, colorValue); - row += 1; - - // ASCII Avatar - if (!string.IsNullOrWhiteSpace(profile?.AvatarAscii)) - { - row += 1; - var avatarFrame = new FrameView - { - Title = "Avatar", - X = 1, - Y = row, - Width = Dim.Fill(1), - Height = 4 - }; - var avatarLabel = new Label - { - Text = profile.AvatarAscii, - X = 0, - Y = 0 - }; - avatarFrame.Add(avatarLabel); - profileFrame.Add(avatarFrame); - } - - dialog.Add(profileFrame); - - // -- Right side: Saved Servers ---------------------------------------- - var serversFrame = new FrameView - { - Title = "Saved Servers", - X = 36, - Y = 0, - Width = Dim.Fill(1), - Height = Dim.Fill(3) - }; - - var serverNames = savedServers.Select(s => s.Name).ToList(); - var serverList = new ListView - { - Source = new ListWrapper(new ObservableCollection(serverNames)), - X = 0, - Y = 0, - Width = Dim.Fill(0), - Height = Dim.Fill(4) - }; - - var serverUrlLabel = new Label - { - Text = "URL: -", - X = 0, - Y = Pos.AnchorEnd(3), - Width = Dim.Fill(0) - }; - var serverLastLabel = new Label - { - Text = "Last: -", - X = 0, - Y = Pos.AnchorEnd(2), - Width = Dim.Fill(0) - }; - var serverUserLabel = new Label - { - Text = "User: -", - X = 0, - Y = Pos.AnchorEnd(1), - Width = Dim.Fill(0) - }; - - serverList.ValueChanged += (sender, e) => - { - var index = e.NewValue; - if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) - { - var server = savedServers[index.Value]; - serverUrlLabel.Text = $"URL: {Truncate(server.Url, 25)}"; - serverLastLabel.Text = $"Last: {server.LastConnected:yyyy-MM-dd HH:mm}"; - serverUserLabel.Text = $"User: {server.Username ?? "-"}"; - } - }; - - // Show initial details if there are servers - if (savedServers.Count > 0) - { - var first = savedServers[0]; - serverUrlLabel.Text = $"URL: {Truncate(first.Url, 25)}"; - serverLastLabel.Text = $"Last: {first.LastConnected:yyyy-MM-dd HH:mm}"; - serverUserLabel.Text = $"User: {first.Username ?? "-"}"; - } - - serversFrame.Add(serverList, serverUrlLabel, serverLastLabel, serverUserLabel); - dialog.Add(serversFrame); - - // -- Bottom buttons --------------------------------------------------- - var editProfileButton = new Button - { - Text = "Edit Profile", - X = Pos.Center() - 22, - Y = Pos.AnchorEnd(2) - }; - - var setStatusButton = new Button - { - Text = "Set Status", - X = Pos.Center() - 5, - Y = Pos.AnchorEnd(2) - }; - - var closeButton = new Button - { - Text = "Close", - IsDefault = true, - X = Pos.Center() + 12, - Y = Pos.AnchorEnd(2) - }; - - editProfileButton.Accepting += (s, e) => - { - action = UserPanelAction.EditProfile; - e.Handled = true; - app.RequestStop(); - }; - - setStatusButton.Accepting += (s, e) => - { - action = UserPanelAction.SetStatus; - e.Handled = true; - app.RequestStop(); - }; - - closeButton.Accepting += (s, e) => - { - action = UserPanelAction.Close; - e.Handled = true; - app.RequestStop(); - }; - - dialog.Add(editProfileButton, setStatusButton, closeButton); - - app.Run(dialog); - - return action; - } - - private static string FormatStatus(UserStatus status) => status switch - { - UserStatus.Online => "\u25cf Online", - UserStatus.Away => "\u25cf Away", - UserStatus.DoNotDisturb => "\u25cf Do Not Disturb", - UserStatus.Invisible => "\u25cb Invisible", - _ => "\u25cf Unknown" - }; - - private static Color GetStatusColor(UserStatus status) => status switch - { - UserStatus.Online => Color.BrightGreen, - UserStatus.Away => Color.BrightYellow, - UserStatus.DoNotDisturb => Color.BrightRed, - UserStatus.Invisible => Color.Gray, - _ => Color.White - }; - - private static string Truncate(string value, int maxLength) => - value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength - 3), "..."); -}