Refactor and update various components of EchoHub.Server

- Updated launchSettings.json for consistency.
- Refactored FileValidationHelper to improve image validation logic.
- Enhanced ServerDirectoryService for better connection handling and user count updates.
- Improved DatabaseSetup for legacy database handling and seeding default channels.
- Refined FirstRunSetup to ensure JWT secret generation.
- Removed appsettings.Development.json as it is no longer needed.
- Updated EchoHub.Tests project file for consistency.
- Added unit tests for FileValidationHelper and PresenceTracker with improved assertions.
- Updated ValidationConstantsTests to ensure regex validations are correct.
- Cleaned up solution file formatting for better readability.
This commit is contained in:
HueByte
2026-02-19 10:12:28 +01:00
parent 32e01e8d71
commit 9975f4e4be
57 changed files with 4401 additions and 4378 deletions
+280 -280
View File
@@ -1,280 +1,280 @@
using System.Collections;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length;
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.Length);
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
int segPos = 0;
while (segPos < segment.Text.Length)
{
int remaining = width - col;
if (remaining <= 0)
{
// Emit current line and start a new one
results.Add(new ChatLine(currentSegments));
currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
remaining = width - col;
}
int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take;
segPos += take;
}
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0;
Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText))
{
// Add any text before this escape sequence
if (match.Index > lastIndex)
{
var text = ansiText[lastIndex..match.Index];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
// Parse the escape sequence
if (match.Groups[1].Value == "0")
{
// Reset
currentColor = defaultAttr;
}
else if (match.Groups[2].Success)
{
// 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black);
}
lastIndex = match.Index + match.Length;
}
// Add remaining text
if (lastIndex < ansiText.Length)
{
var text = ansiText[lastIndex..];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-character coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
RaiseCollectionChanged();
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).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 chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr);
foreach (var ch in segment.Text)
{
if (charPos >= viewportX && drawnChars < width)
{
listView.AddRune(new Rune(ch));
drawnChars++;
}
charPos++;
}
}
// Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class ColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black);
}
catch
{
return null;
}
}
}
using System.Collections;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.Length;
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.Length);
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
int segPos = 0;
while (segPos < segment.Text.Length)
{
int remaining = width - col;
if (remaining <= 0)
{
// Emit current line and start a new one
results.Add(new ChatLine(currentSegments));
currentSegments = [];
// Add indent for continuation
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
remaining = width - col;
}
int take = Math.Min(segment.Text.Length - segPos, remaining);
currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color));
col += take;
segPos += take;
}
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
return results;
}
/// <summary>
/// Parse a string containing ANSI 24-bit color escape codes into colored segments.
/// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset)
/// </summary>
public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
var regex = AnsiColorRegex();
int lastIndex = 0;
Attribute? currentColor = defaultAttr;
foreach (Match match in regex.Matches(ansiText))
{
// Add any text before this escape sequence
if (match.Index > lastIndex)
{
var text = ansiText[lastIndex..match.Index];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
// Parse the escape sequence
if (match.Groups[1].Value == "0")
{
// Reset
currentColor = defaultAttr;
}
else if (match.Groups[2].Success)
{
// 38;2;R;G;B — 24-bit foreground color
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
currentColor = new Attribute(new Color(r, g, b), Color.Black);
}
lastIndex = match.Index + match.Length;
}
// Add remaining text
if (lastIndex < ansiText.Length)
{
var text = ansiText[lastIndex..];
if (text.Length > 0)
segments.Add(new ChatSegment(text, currentColor));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
// Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
/// <summary>
/// Custom list data source for chat messages with per-character coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _lines = [];
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _lines.Count;
public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; }
public void Add(ChatLine line)
{
_lines.Add(line);
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void AddRange(IEnumerable<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> lines)
{
var items = lines.ToList();
_lines.InsertRange(index, items);
foreach (var line in items)
UpdateMaxLength(line);
RaiseCollectionChanged();
}
public void Clear()
{
_lines.Clear();
MaxItemLength = 0;
RaiseCollectionChanged();
}
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).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 chatLine = _lines[item];
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
listView.SetAttribute(attr);
foreach (var ch in segment.Text)
{
if (charPos >= viewportX && drawnChars < width)
{
listView.AddRune(new Rune(ch));
drawnChars++;
}
charPos++;
}
}
// Fill remaining width with spaces using default colors
listView.SetAttribute(normalAttr);
while (drawnChars < width)
{
listView.AddRune(new Rune(' '));
drawnChars++;
}
}
private void UpdateMaxLength(ChatLine line)
{
if (line.TextLength > MaxItemLength)
MaxItemLength = line.TextLength;
}
private void RaiseCollectionChanged()
{
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void Dispose() { }
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
public static class ColorHelper
{
public static Attribute? ParseHexColor(string? hex)
{
if (string.IsNullOrWhiteSpace(hex))
return null;
hex = hex.TrimStart('#');
if (hex.Length != 6)
return null;
try
{
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black);
}
catch
{
return null;
}
}
}
+77 -77
View File
@@ -1,77 +1,77 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic);
public sealed class CreateChannelDialog
{
public static CreateChannelResult? Show(IApplication app)
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 5,
};
var createButton = new Button
{
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 7
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 7
};
createButton.Accepting += (s, e) =>
{
var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return;
}
var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic))
topic = null;
result = new CreateChannelResult(name, topic);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
}
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic);
public sealed class CreateChannelDialog
{
public static CreateChannelResult? Show(IApplication app)
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 12 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 5,
};
var createButton = new Button
{
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 7
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 7
};
createButton.Accepting += (s, e) =>
{
var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return;
}
var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic))
topic = null;
result = new CreateChannelResult(name, topic);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
}
+246 -246
View File
@@ -1,246 +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;
/// <summary>
/// Action selected by the user in their own profile dialog.
/// </summary>
public enum ProfileAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary>
public sealed class ProfileViewDialog
{
/// <summary>
/// Show a read-only profile view for another user.
/// </summary>
public static void Show(IApplication app, UserProfileDto? profile)
{
ShowInternal(app, profile, isOwnProfile: false);
}
/// <summary>
/// Show the profile view for the current user with action buttons.
/// Returns the action the user selected.
/// </summary>
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
};
}
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;
/// <summary>
/// Action selected by the user in their own profile dialog.
/// </summary>
public enum ProfileAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary>
public sealed class ProfileViewDialog
{
/// <summary>
/// Show a read-only profile view for another user.
/// </summary>
public static void Show(IApplication app, UserProfileDto? profile)
{
ShowInternal(app, profile, isOwnProfile: false);
}
/// <summary>
/// Show the profile view for the current user with action buttons.
/// Returns the action the user selected.
/// </summary>
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
};
}