mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 07:36:03 +02:00
Add core models, DTOs, and services for EchoHub chat application
- Created User, Channel, Message, and ServerInfo models with necessary properties. - Added DTOs for user profiles, server information, and message handling. - Implemented JWT authentication service for user login and token generation. - Developed Entity Framework Core DbContext for database interactions. - Introduced SignalR ChatHub for real-time messaging and presence tracking. - Implemented file storage and image to ASCII conversion services. - Set up CORS and middleware for API endpoints. - Created launch settings and development configuration for server and web projects.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using EchoHub.Client.Config;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
using Terminal.Gui.ViewBase;
|
||||
|
||||
namespace EchoHub.Client.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned from the connect dialog.
|
||||
/// </summary>
|
||||
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister);
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||
/// Includes a saved servers selector when saved servers are available.
|
||||
/// </summary>
|
||||
public sealed class ConnectDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the connect dialog with an optional list of saved servers.
|
||||
/// Returns the result, or null if cancelled.
|
||||
/// </summary>
|
||||
public static ConnectDialogResult? Show(IApplication app, List<SavedServer>? savedServers = null)
|
||||
{
|
||||
ConnectDialogResult? result = null;
|
||||
savedServers ??= [];
|
||||
|
||||
var hasSavedServers = savedServers.Count > 0;
|
||||
var dialogHeight = hasSavedServers ? 20 : 16;
|
||||
|
||||
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||
|
||||
int yOffset = 0;
|
||||
|
||||
// -- Saved Servers section (if any) -----------------------------------
|
||||
ListView? savedServerList = null;
|
||||
if (hasSavedServers)
|
||||
{
|
||||
var savedLabel = new Label
|
||||
{
|
||||
Text = "Saved Servers:",
|
||||
X = 1,
|
||||
Y = 1
|
||||
};
|
||||
dialog.Add(savedLabel);
|
||||
|
||||
var serverDisplayNames = savedServers
|
||||
.Select(s => $"{s.Name} ({s.Username ?? "?"})")
|
||||
.ToList();
|
||||
|
||||
savedServerList = new ListView
|
||||
{
|
||||
Source = new ListWrapper<string>(new ObservableCollection<string>(serverDisplayNames)),
|
||||
X = 1,
|
||||
Y = 2,
|
||||
Width = Dim.Fill(2),
|
||||
Height = 3
|
||||
};
|
||||
dialog.Add(savedServerList);
|
||||
|
||||
// Visual separator
|
||||
var separator = new Label
|
||||
{
|
||||
Text = new string('-', 56),
|
||||
X = 1,
|
||||
Y = 5
|
||||
};
|
||||
dialog.Add(separator);
|
||||
|
||||
yOffset = 5;
|
||||
}
|
||||
|
||||
// -- Manual entry fields ----------------------------------------------
|
||||
var urlLabel = new Label
|
||||
{
|
||||
Text = "Server URL:",
|
||||
X = 1,
|
||||
Y = yOffset + 1
|
||||
};
|
||||
var urlField = new TextField
|
||||
{
|
||||
Text = "http://localhost:5000",
|
||||
X = 15,
|
||||
Y = yOffset + 1,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var userLabel = new Label
|
||||
{
|
||||
Text = "Username:",
|
||||
X = 1,
|
||||
Y = yOffset + 3
|
||||
};
|
||||
var userField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 15,
|
||||
Y = yOffset + 3,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var passLabel = new Label
|
||||
{
|
||||
Text = "Password:",
|
||||
X = 1,
|
||||
Y = yOffset + 5
|
||||
};
|
||||
var passField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 15,
|
||||
Y = yOffset + 5,
|
||||
Width = Dim.Fill(2),
|
||||
Secret = true
|
||||
};
|
||||
|
||||
var displayLabel = new Label
|
||||
{
|
||||
Text = "Display Name:",
|
||||
X = 1,
|
||||
Y = yOffset + 7
|
||||
};
|
||||
var displayField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 15,
|
||||
Y = yOffset + 7,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var loginButton = new Button
|
||||
{
|
||||
Text = "Login",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 20,
|
||||
Y = yOffset + 9
|
||||
};
|
||||
|
||||
var registerButton = new Button
|
||||
{
|
||||
Text = "Register",
|
||||
X = Pos.Center() - 5,
|
||||
Y = yOffset + 9
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 10,
|
||||
Y = yOffset + 9
|
||||
};
|
||||
|
||||
// Wire saved server selection to auto-fill fields
|
||||
if (savedServerList is not null && savedServers.Count > 0)
|
||||
{
|
||||
savedServerList.ValueChanged += (sender, e) =>
|
||||
{
|
||||
var index = e.NewValue;
|
||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||
{
|
||||
var server = savedServers[index.Value];
|
||||
urlField.Text = server.Url;
|
||||
userField.Text = server.Username ?? "";
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-fill with the first saved server
|
||||
urlField.Text = savedServers[0].Url;
|
||||
userField.Text = savedServers[0].Username ?? "";
|
||||
}
|
||||
|
||||
loginButton.Accepting += (s, e) =>
|
||||
{
|
||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||
var user = userField.Text?.Trim() ?? string.Empty;
|
||||
var pass = passField.Text ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||
{
|
||||
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: false);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
registerButton.Accepting += (s, e) =>
|
||||
{
|
||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||
var user = userField.Text?.Trim() ?? string.Empty;
|
||||
var pass = passField.Text ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||
{
|
||||
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||
displayLabel, displayField, loginButton, registerButton, cancelButton);
|
||||
|
||||
if (hasSavedServers && savedServerList is not null)
|
||||
savedServerList.SetFocus();
|
||||
else
|
||||
urlField.SetFocus();
|
||||
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using EchoHub.Client.Themes;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Configuration;
|
||||
using Terminal.Gui.Input;
|
||||
|
||||
namespace EchoHub.Client.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Main Terminal.Gui window for the EchoHub chat client.
|
||||
/// </summary>
|
||||
public sealed class MainWindow : Window
|
||||
{
|
||||
private readonly IApplication _app;
|
||||
private readonly ListView _channelList;
|
||||
private readonly ListView _messageList;
|
||||
private readonly TextView _inputField;
|
||||
private readonly FrameView _chatFrame;
|
||||
private readonly Label _statusLabel;
|
||||
private MenuBar _menuBar;
|
||||
|
||||
private readonly List<string> _channelNames = [];
|
||||
private readonly Dictionary<string, List<string>> _channelMessages = [];
|
||||
private string _currentChannel = string.Empty;
|
||||
private string _currentUser = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user selects a channel. Parameter is the channel name.
|
||||
/// </summary>
|
||||
public event Action<string>? OnChannelSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user presses Enter in the input field. Parameters: channel name, message content.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnMessageSubmitted;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to connect via the menu.
|
||||
/// </summary>
|
||||
public event Action? OnConnectRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to disconnect via the menu.
|
||||
/// </summary>
|
||||
public event Action? OnDisconnectRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to open their profile panel.
|
||||
/// </summary>
|
||||
public event Action? OnProfileRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to set their status.
|
||||
/// </summary>
|
||||
public event Action? OnStatusRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user selects a theme from the menu. Parameter is the theme name.
|
||||
/// </summary>
|
||||
public event Action<string>? OnThemeSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to view saved servers.
|
||||
/// </summary>
|
||||
public event Action? OnSavedServersRequested;
|
||||
|
||||
public MainWindow(IApplication app)
|
||||
{
|
||||
_app = app;
|
||||
Title = "EchoHub";
|
||||
BorderStyle = LineStyle.None;
|
||||
|
||||
// Menu bar at the top
|
||||
_menuBar = BuildMenuBar();
|
||||
Add(_menuBar);
|
||||
|
||||
// Left panel - channels
|
||||
var channelsFrame = new FrameView
|
||||
{
|
||||
Title = "Channels",
|
||||
X = 0,
|
||||
Y = 1, // below menu bar
|
||||
Width = 25,
|
||||
Height = Dim.Fill(1) // leave room for status bar
|
||||
};
|
||||
|
||||
_channelList = new ListView
|
||||
{
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = Dim.Fill(),
|
||||
Height = Dim.Fill()
|
||||
};
|
||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||
_channelList.ValueChanged += OnChannelListSelectionChanged;
|
||||
channelsFrame.Add(_channelList);
|
||||
Add(channelsFrame);
|
||||
|
||||
// Center panel - messages
|
||||
_chatFrame = new FrameView
|
||||
{
|
||||
Title = "Chat",
|
||||
X = 25,
|
||||
Y = 1, // below menu bar
|
||||
Width = Dim.Fill(),
|
||||
Height = Dim.Fill(6) // leave room for input area and status bar
|
||||
};
|
||||
|
||||
_messageList = new ListView
|
||||
{
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = Dim.Fill(),
|
||||
Height = Dim.Fill()
|
||||
};
|
||||
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
||||
_chatFrame.Add(_messageList);
|
||||
Add(_chatFrame);
|
||||
|
||||
// Bottom input area (multiline)
|
||||
var inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message (Enter=send, Shift+Enter=newline)",
|
||||
X = 25,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(),
|
||||
Height = 5
|
||||
};
|
||||
|
||||
_inputField = new TextView
|
||||
{
|
||||
X = 0,
|
||||
Y = 0,
|
||||
Width = Dim.Fill(),
|
||||
Height = Dim.Fill(),
|
||||
Text = "",
|
||||
WordWrap = true
|
||||
};
|
||||
_inputField.KeyDown += OnInputKeyDown;
|
||||
inputFrame.Add(_inputField);
|
||||
Add(inputFrame);
|
||||
|
||||
// Status bar at the very bottom
|
||||
_statusLabel = new Label
|
||||
{
|
||||
Text = "Disconnected",
|
||||
X = 0,
|
||||
Y = Pos.AnchorEnd(1),
|
||||
Width = Dim.Fill(),
|
||||
Height = 1
|
||||
};
|
||||
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
||||
Add(_statusLabel);
|
||||
|
||||
// Apply our custom color schemes to all views
|
||||
ApplyColorSchemes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the currently registered color schemes to all views.
|
||||
/// Call after theme changes to refresh colors.
|
||||
/// </summary>
|
||||
public void ApplyColorSchemes()
|
||||
{
|
||||
var baseScheme = SchemeManager.GetScheme("Base");
|
||||
var menuScheme = SchemeManager.GetScheme("Menu");
|
||||
|
||||
if (baseScheme is not null)
|
||||
{
|
||||
this.SetScheme(baseScheme);
|
||||
|
||||
// Propagate to all child views that should use the base scheme
|
||||
foreach (var sub in SubViews)
|
||||
{
|
||||
if (sub != _menuBar && sub != _statusLabel)
|
||||
sub.SetScheme(baseScheme);
|
||||
}
|
||||
}
|
||||
|
||||
if (menuScheme is not null)
|
||||
{
|
||||
_menuBar.SetScheme(menuScheme);
|
||||
_statusLabel.SetScheme(menuScheme);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the menu bar with File, Server, User menus and a theme submenu.
|
||||
/// </summary>
|
||||
private MenuBar BuildMenuBar()
|
||||
{
|
||||
// Build theme menu items and prepend them with a separator header
|
||||
var themeItems = new List<MenuItem>();
|
||||
foreach (var t in Themes.ThemeManager.GetAvailableThemes())
|
||||
{
|
||||
var name = t.Name;
|
||||
themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty));
|
||||
}
|
||||
|
||||
// Combine user items with theme items, separated by a line
|
||||
var userMenuChildren = new MenuItem[]
|
||||
{
|
||||
new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty),
|
||||
new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty),
|
||||
};
|
||||
|
||||
// Merge: user items + separator + theme items
|
||||
var allUserItems = new MenuItem[userMenuChildren.Length + 1 + themeItems.Count];
|
||||
userMenuChildren.CopyTo(allUserItems, 0);
|
||||
allUserItems[userMenuChildren.Length] = null!; // null separator
|
||||
for (int i = 0; i < themeItems.Count; i++)
|
||||
allUserItems[userMenuChildren.Length + 1 + i] = themeItems[i];
|
||||
|
||||
return new MenuBar(
|
||||
[
|
||||
new MenuBarItem("_File",
|
||||
[
|
||||
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)
|
||||
]),
|
||||
new MenuBarItem("_Server",
|
||||
[
|
||||
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
|
||||
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||
null!,
|
||||
new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty)
|
||||
]),
|
||||
new MenuBarItem("_User", allUserItems)
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds and replaces the menu bar (e.g., after theme list changes).
|
||||
/// </summary>
|
||||
public void RefreshMenuBar()
|
||||
{
|
||||
Remove(_menuBar);
|
||||
_menuBar = BuildMenuBar();
|
||||
Add(_menuBar);
|
||||
ApplyColorSchemes();
|
||||
SetNeedsDraw();
|
||||
}
|
||||
|
||||
private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs<int?> e)
|
||||
{
|
||||
var index = e.NewValue;
|
||||
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
||||
{
|
||||
var channelName = _channelNames[index.Value];
|
||||
if (channelName != _currentChannel)
|
||||
{
|
||||
SwitchToChannel(channelName);
|
||||
OnChannelSelected?.Invoke(channelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInputKeyDown(object? sender, Key e)
|
||||
{
|
||||
if (e == Key.Enter.WithShift)
|
||||
{
|
||||
// Shift+Enter: let TextView handle it (inserts newline)
|
||||
return;
|
||||
}
|
||||
|
||||
if (e == Key.Enter)
|
||||
{
|
||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
|
||||
{
|
||||
OnMessageSubmitted?.Invoke(_currentChannel, text);
|
||||
_inputField.Text = string.Empty;
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a message to the specified channel's message list and refresh if it is the current channel.
|
||||
/// </summary>
|
||||
public void AddMessage(MessageDto message)
|
||||
{
|
||||
var lines = FormatMessage(message);
|
||||
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
|
||||
{
|
||||
messages = [];
|
||||
_channelMessages[message.ChannelName] = messages;
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
messages.Add(line);
|
||||
}
|
||||
|
||||
if (message.ChannelName == _currentChannel)
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a system/informational message to a channel.
|
||||
/// </summary>
|
||||
public void AddSystemMessage(string channelName, string text)
|
||||
{
|
||||
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}";
|
||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||
{
|
||||
messages = [];
|
||||
_channelMessages[channelName] = messages;
|
||||
}
|
||||
messages.Add(formatted);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a status change message to a channel.
|
||||
/// </summary>
|
||||
public void AddStatusMessage(string channelName, string username, string status)
|
||||
{
|
||||
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}";
|
||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||
{
|
||||
messages = [];
|
||||
_channelMessages[channelName] = messages;
|
||||
}
|
||||
messages.Add(formatted);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the list of available channels and refresh the channel list view.
|
||||
/// </summary>
|
||||
public void SetChannels(List<ChannelDto> channels)
|
||||
{
|
||||
_channelNames.Clear();
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_channelNames.Add(ch.Name);
|
||||
if (!_channelMessages.ContainsKey(ch.Name))
|
||||
_channelMessages[ch.Name] = [];
|
||||
}
|
||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show an error message to the user.
|
||||
/// </summary>
|
||||
public void ShowError(string message)
|
||||
{
|
||||
MessageBox.ErrorQuery(_app, "Error", message, "OK");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the connection status displayed in the status bar.
|
||||
/// </summary>
|
||||
public void UpdateStatusBar(string status)
|
||||
{
|
||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" | User: {_currentUser}";
|
||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" | #{_currentChannel}";
|
||||
_statusLabel.Text = $" {status}{userPart}{channelPart}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the current user name for display in the status bar.
|
||||
/// </summary>
|
||||
public void SetCurrentUser(string username)
|
||||
{
|
||||
_currentUser = username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current channel name.
|
||||
/// </summary>
|
||||
public string CurrentChannel => _currentChannel;
|
||||
|
||||
/// <summary>
|
||||
/// Get all channel names that have message buffers (for broadcasting status changes).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Switch the chat view to the given channel.
|
||||
/// </summary>
|
||||
public void SwitchToChannel(string channelName)
|
||||
{
|
||||
_currentChannel = channelName;
|
||||
_chatFrame.Title = $"#{channelName}";
|
||||
RefreshMessages();
|
||||
|
||||
// Update channel list selection
|
||||
var idx = _channelNames.IndexOf(channelName);
|
||||
if (idx >= 0)
|
||||
_channelList.SelectedItem = idx;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load historical messages into a channel (prepend).
|
||||
/// </summary>
|
||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||
{
|
||||
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||
{
|
||||
existing = [];
|
||||
_channelMessages[channelName] = existing;
|
||||
}
|
||||
|
||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
||||
existing.InsertRange(0, formatted);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
{
|
||||
RefreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all messages and channels (used on disconnect).
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelMessages.Clear();
|
||||
_currentChannel = string.Empty;
|
||||
_currentUser = string.Empty;
|
||||
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
|
||||
_chatFrame.Title = "Chat";
|
||||
RefreshMessages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focus the input field for typing.
|
||||
/// </summary>
|
||||
public void FocusInput()
|
||||
{
|
||||
_inputField.SetFocus();
|
||||
}
|
||||
|
||||
private void RefreshMessages()
|
||||
{
|
||||
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
||||
{
|
||||
_messageList.SetSource(new ObservableCollection<string>(messages));
|
||||
if (messages.Count > 0)
|
||||
_messageList.SelectedItem = messages.Count - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
_messageList.SetSource(new ObservableCollection<string>(new List<string>()));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
||||
/// </summary>
|
||||
private static List<string> FormatMessage(MessageDto message)
|
||||
{
|
||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||
var sender = message.SenderNicknameColor is not null
|
||||
? $"<{message.SenderUsername}>"
|
||||
: message.SenderUsername + ":";
|
||||
|
||||
var lines = new List<string>();
|
||||
|
||||
switch (message.Type)
|
||||
{
|
||||
case MessageType.Image:
|
||||
lines.Add($"[{time}] {sender} [Image]");
|
||||
// Content IS the ASCII art — add each line as a separate list item
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
foreach (var artLine in message.Content.Split('\n'))
|
||||
{
|
||||
lines.Add($" {artLine}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MessageType.File:
|
||||
var fileName = message.AttachmentFileName ?? "unknown";
|
||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
||||
lines.Add($"[{time}] {sender} [File: {fileName}]{fileContent}");
|
||||
break;
|
||||
|
||||
case MessageType.Text:
|
||||
default:
|
||||
var contentLines = message.Content.Split('\n');
|
||||
lines.Add($"[{time}] {sender} {contentLines[0]}");
|
||||
// Continuation lines indented to align with first line's content
|
||||
for (int i = 1; i < contentLines.Length; i++)
|
||||
{
|
||||
lines.Add($" {contentLines[i]}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned from the profile edit dialog.
|
||||
/// </summary>
|
||||
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor);
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
|
||||
/// </summary>
|
||||
public sealed class ProfileEditDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the profile edit dialog and returns the result, or null if cancelled.
|
||||
/// </summary>
|
||||
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor)
|
||||
{
|
||||
ProfileEditResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 18 };
|
||||
|
||||
// Display Name
|
||||
var nameLabel = new Label
|
||||
{
|
||||
Text = "Display Name:",
|
||||
X = 1,
|
||||
Y = 1
|
||||
};
|
||||
var nameField = new TextField
|
||||
{
|
||||
Text = currentDisplayName ?? "",
|
||||
X = 17,
|
||||
Y = 1,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
// Bio
|
||||
var bioLabel = new Label
|
||||
{
|
||||
Text = "Bio:",
|
||||
X = 1,
|
||||
Y = 3
|
||||
};
|
||||
var bioField = new TextField
|
||||
{
|
||||
Text = currentBio ?? "",
|
||||
X = 17,
|
||||
Y = 3,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
// Nickname Color
|
||||
var colorLabel = new Label
|
||||
{
|
||||
Text = "Nickname Color:",
|
||||
X = 1,
|
||||
Y = 5
|
||||
};
|
||||
var colorField = new TextField
|
||||
{
|
||||
Text = currentColor ?? "",
|
||||
X = 17,
|
||||
Y = 5,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var colorHintLabel = new Label
|
||||
{
|
||||
Text = "(hex e.g. #FF5733)",
|
||||
X = 17,
|
||||
Y = 6
|
||||
};
|
||||
colorHintLabel.SetScheme(new Scheme
|
||||
{
|
||||
Normal = new Attribute(Color.DarkGray, Color.Blue)
|
||||
});
|
||||
|
||||
// Color Preview
|
||||
var previewLabel = new Label
|
||||
{
|
||||
Text = "Preview:",
|
||||
X = 1,
|
||||
Y = 8
|
||||
};
|
||||
var colorPreview = new Label
|
||||
{
|
||||
Text = "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
X = 17,
|
||||
Y = 8
|
||||
};
|
||||
|
||||
UpdateColorPreview(colorPreview, colorField.Text);
|
||||
|
||||
colorField.TextChanged += (sender, e) =>
|
||||
{
|
||||
UpdateColorPreview(colorPreview, colorField.Text);
|
||||
};
|
||||
|
||||
// Buttons
|
||||
var saveButton = new Button
|
||||
{
|
||||
Text = "Save",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 10,
|
||||
Y = 10
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 5,
|
||||
Y = 10
|
||||
};
|
||||
|
||||
saveButton.Accepting += (s, e) =>
|
||||
{
|
||||
var displayName = NullIfEmpty(nameField.Text?.Trim());
|
||||
var bio = NullIfEmpty(bioField.Text?.Trim());
|
||||
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
|
||||
|
||||
result = new ProfileEditResult(displayName, bio, nicknameColor);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
|
||||
colorHintLabel, previewLabel, colorPreview, saveButton, cancelButton);
|
||||
|
||||
nameField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a hex color string and update the preview label color.
|
||||
/// </summary>
|
||||
private static void UpdateColorPreview(Label preview, string? hexColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hexColor))
|
||||
{
|
||||
preview.SetScheme(new Scheme
|
||||
{
|
||||
Normal = new Attribute(Color.White, Color.Blue)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var color = ParseHexToTrueColor(hexColor.Trim());
|
||||
preview.SetScheme(new Scheme
|
||||
{
|
||||
Normal = new Attribute(color, Color.Blue)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
|
||||
/// V2 supports TrueColor via new Color(r, g, b).
|
||||
/// </summary>
|
||||
private static Color ParseHexToTrueColor(string hex)
|
||||
{
|
||||
if (hex.StartsWith('#'))
|
||||
hex = hex[1..];
|
||||
|
||||
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
|
||||
return Color.White;
|
||||
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
|
||||
return new Color(r, g, b);
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using EchoHub.Core.Models;
|
||||
|
||||
namespace EchoHub.Client.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned from the status dialog.
|
||||
/// </summary>
|
||||
public record StatusDialogResult(UserStatus Status, string? StatusMessage);
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for setting the user's status and status message.
|
||||
/// </summary>
|
||||
public sealed class StatusDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the status dialog and returns the result, or null if cancelled.
|
||||
/// </summary>
|
||||
public static StatusDialogResult? Show(IApplication app, UserStatus currentStatus, string? currentMessage)
|
||||
{
|
||||
StatusDialogResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Set Status", Width = 50, Height = 12 };
|
||||
|
||||
var statusLabel = new Label
|
||||
{
|
||||
Text = "Status:",
|
||||
X = 1,
|
||||
Y = 1
|
||||
};
|
||||
|
||||
var optionSelector = new OptionSelector<UserStatus>
|
||||
{
|
||||
X = 12,
|
||||
Y = 1
|
||||
};
|
||||
optionSelector.Value = currentStatus;
|
||||
|
||||
var messageLabel = new Label
|
||||
{
|
||||
Text = "Message:",
|
||||
X = 1,
|
||||
Y = 6
|
||||
};
|
||||
var messageField = new TextField
|
||||
{
|
||||
Text = currentMessage ?? "",
|
||||
X = 12,
|
||||
Y = 6,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
var saveButton = new Button
|
||||
{
|
||||
Text = "Save",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 10,
|
||||
Y = 8
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 5,
|
||||
Y = 8
|
||||
};
|
||||
|
||||
saveButton.Accepting += (s, e) =>
|
||||
{
|
||||
var status = optionSelector.Value ?? UserStatus.Online;
|
||||
var message = messageField.Text?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
message = null;
|
||||
|
||||
result = new StatusDialogResult(status, message);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.Add(statusLabel, optionSelector, messageLabel, messageField, saveButton, cancelButton);
|
||||
|
||||
optionSelector.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Action selected by the user in the user panel dialog.
|
||||
/// </summary>
|
||||
public enum UserPanelAction
|
||||
{
|
||||
Close,
|
||||
EditProfile,
|
||||
SetStatus
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Terminal.Gui dialog for viewing the user panel -- profile info, saved servers, and status.
|
||||
/// </summary>
|
||||
public sealed class UserPanelDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Shows the user panel dialog and returns the action the user selected.
|
||||
/// </summary>
|
||||
public static UserPanelAction Show(
|
||||
IApplication app,
|
||||
UserProfileDto? profile,
|
||||
List<SavedServer> 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
|
||||
};
|
||||
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<string>(new ObservableCollection<string>(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), "...");
|
||||
}
|
||||
Reference in New Issue
Block a user