mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user