mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Add hex color parsing helper and implement custom list sources for channels and users
- Introduced HexColorHelper for parsing hex color strings to Terminal.Gui Attributes and Colors. - Created ChannelListSource for managing and rendering a list of channels with unread counts and active indicators. - Developed UserListSource for displaying online users with per-user nickname colors. - Refactored MainWindow to utilize ChatMessageManager for handling messages and channel state. - Updated project references and removed unused content from the server project. - Adjusted tests to reflect changes in namespaces and structure.
This commit is contained in:
@@ -102,6 +102,7 @@
|
|||||||
### EchoHub Branding
|
### EchoHub Branding
|
||||||
|
|
||||||
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
|
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
|
||||||
|
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# v0.2.6
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
- Extracted `ChatMessageManager` from `MainWindow` — message storage, formatting, and mutation logic now in a dedicated class, reducing MainWindow complexity
|
||||||
|
- Split `ChatRenderer.cs` (8 classes, 548 lines) into 7 individual files: `ChatSegment`, `ChatLine`, `ChatListSource`, `ChannelListSource`, `UserListSource`, `ChatColors`, `ColorHelper`, `RenderHelpers`
|
||||||
|
- Extracted `ConnectionManager` from `AppOrchestrator` — connection lifecycle, authentication, SignalR event wiring, and channel tracking now in a dedicated service
|
||||||
|
- Extracted `AvatarHelper` — deduplicated avatar upload logic previously duplicated in `/avatar` command and profile edit dialog
|
||||||
|
- Consolidated `ProfileEditDialog.ParseHexToTrueColor` into shared `ColorHelper.ParseHexToColor`
|
||||||
|
- Extracted `UserSession` — session state (`Username`, `Status`, `StatusMessage`) now in a dedicated class instead of scattered fields
|
||||||
|
- Replaced 21 inline command handler lambdas with named `HandleCmd*` methods for improved readability
|
||||||
|
- Extracted shared `CleanupConnectionAsync` to deduplicate disconnect/logout cleanup logic
|
||||||
|
- Reorganized flat `UI/` folder (19 files) into subfolders: `Chat/`, `Dialogs/`, `ListSources/`, `Helpers/` with matching namespaces
|
||||||
|
- Moved `AsyncRunner` from project root to `Services/` with updated namespace
|
||||||
|
- Renamed `ColorHelper` → `HexColorHelper` to avoid namespace collision with Terminal.Gui's `ColorHelper` NuGet dependency
|
||||||
|
- Moved `hue_icon.ico` to `Client/Assets/`, removed duplicate from Server (Server now references shared icon via relative path)
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Release workflow: use `--notes-file` instead of inline `--notes` interpolation to prevent shell expansion of special characters in commit messages
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.5</Version>
|
<Version>0.2.6</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@
|
|||||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
<Content Include="hue_icon.ico" />
|
|
||||||
<Content Include="Assets\**">
|
<Content Include="Assets\**">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<PackageIcon></PackageIcon>
|
<PackageIcon></PackageIcon>
|
||||||
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
<ApplicationIcon>Assets\hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
|
|
||||||
namespace EchoHub.Client;
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared avatar upload logic — resolves a file path or URL to a stream
|
||||||
|
/// and uploads it via ApiClient.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AvatarHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Upload an avatar from a local file path or HTTP(S) URL.
|
||||||
|
/// Returns the ASCII art response from the server.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<string?> UploadAsync(ApiClient apiClient, string target)
|
||||||
|
{
|
||||||
|
Stream stream;
|
||||||
|
string fileName;
|
||||||
|
|
||||||
|
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var bytes = await http.GetByteArrayAsync(uri);
|
||||||
|
stream = new MemoryStream(bytes);
|
||||||
|
fileName = Path.GetFileName(uri.LocalPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||||
|
fileName = "avatar.png";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!File.Exists(target))
|
||||||
|
throw new FileNotFoundException($"File not found: {target}");
|
||||||
|
|
||||||
|
stream = File.OpenRead(target);
|
||||||
|
fileName = Path.GetFileName(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (stream)
|
||||||
|
{
|
||||||
|
return await apiClient.UploadAvatarAsync(stream, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a successful connection, returned to AppOrchestrator for UI updates.
|
||||||
|
/// </summary>
|
||||||
|
internal record ConnectResult(
|
||||||
|
LoginResponse Login,
|
||||||
|
List<ChannelDto> Channels,
|
||||||
|
List<MessageDto> DefaultHistory);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns connection lifecycle, authentication, SignalR event wiring, and channel tracking.
|
||||||
|
/// Fires events so AppOrchestrator can update the UI without managing connection internals.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ConnectionManager : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private EchoHubConnection? _connection;
|
||||||
|
private ApiClient? _apiClient;
|
||||||
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
|
private readonly HashSet<string> _joinedChannels = [];
|
||||||
|
|
||||||
|
// ── Properties ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public bool IsConnected => _connection?.IsConnected == true;
|
||||||
|
public bool IsAuthenticated => _apiClient is not null;
|
||||||
|
public ApiClient? Api => _apiClient;
|
||||||
|
|
||||||
|
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||||
|
|
||||||
|
public event Action<MessageDto>? MessageReceived;
|
||||||
|
public event Action<string, string>? UserJoined;
|
||||||
|
public event Action<string, string>? UserLeft;
|
||||||
|
public event Action<UserPresenceDto>? UserStatusChanged;
|
||||||
|
public event Action<string, string, string?>? UserKicked;
|
||||||
|
public event Action<string, string?>? UserBanned;
|
||||||
|
public event Action<string>? ForceDisconnected;
|
||||||
|
public event Action<string, Guid>? MessageDeleted;
|
||||||
|
public event Action<string>? ChannelNuked;
|
||||||
|
public event Action<ChannelDto>? ChannelUpdated;
|
||||||
|
public event Action<string>? Error;
|
||||||
|
public event Action<string>? ConnectionStatusChanged;
|
||||||
|
public event Action? Reconnected;
|
||||||
|
|
||||||
|
// ── Connect ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full connection flow: authenticate → encryption → SignalR → join default channel.
|
||||||
|
/// Calls <paramref name="onStatus"/> with progress messages for UI updates.
|
||||||
|
/// Throws on auth failure (caller handles saved-session expiry, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ConnectResult> ConnectAsync(ConnectDialogResult info, Action<string> onStatus)
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = new ApiClient(info.ServerUrl);
|
||||||
|
|
||||||
|
onStatus("Authenticating...");
|
||||||
|
|
||||||
|
LoginResponse loginResponse;
|
||||||
|
|
||||||
|
if (info.SavedRefreshToken is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(info.SavedRefreshToken);
|
||||||
|
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_apiClient.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
throw; // Caller handles saved-session expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (info.IsRegister)
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
loginResponse = await _apiClient.LoginAsync(info.Username, info.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-persist rotated refresh tokens for Remember Me
|
||||||
|
_apiClient.OnTokensRefreshed += HandleTokensRefreshed;
|
||||||
|
|
||||||
|
// E2E encryption key
|
||||||
|
onStatus("Fetching encryption key...");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
||||||
|
_encryption.SetKey(encryptionKey);
|
||||||
|
Log.Information("E2E encryption key established");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus("Authenticated, connecting...");
|
||||||
|
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
|
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
||||||
|
WireConnectionEvents(_connection);
|
||||||
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
var channels = await _apiClient.GetChannelsAsync();
|
||||||
|
onStatus("Connected");
|
||||||
|
|
||||||
|
// Join default channel + fetch history
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
_joinedChannels.Add(HubConstants.DefaultChannel);
|
||||||
|
await _connection.JoinChannelAsync(HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
List<MessageDto> history = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
history = await _connection.GetHistoryAsync(HubConstants.DefaultChannel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// History might not be available
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConnectResult(loginResponse, channels, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disconnect and dispose connection + API client, clear channel tracking.
|
||||||
|
/// </summary>
|
||||||
|
public async Task CleanupAsync()
|
||||||
|
{
|
||||||
|
if (_connection is not null)
|
||||||
|
{
|
||||||
|
await _connection.DisconnectAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
_connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
_apiClient = null;
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revoke refresh token on the server. Call <see cref="CleanupAsync"/> afterwards.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LogoutAsync()
|
||||||
|
{
|
||||||
|
if (_apiClient is not null)
|
||||||
|
await _apiClient.LogoutAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel Operations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
_joinedChannels.Add(channelName);
|
||||||
|
return await _connection.JoinChannelAsync(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
|
{
|
||||||
|
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||||
|
await _connection.LeaveChannelAsync(channelName);
|
||||||
|
_joinedChannels.Remove(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track a channel as joined (returns true if newly added).
|
||||||
|
/// </summary>
|
||||||
|
public bool TrackChannel(string channelName) => _joinedChannels.Add(channelName);
|
||||||
|
|
||||||
|
public void UntrackChannel(string channelName) => _joinedChannels.Remove(channelName);
|
||||||
|
|
||||||
|
// ── Delegate Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task SendMessageAsync(string channel, string content) =>
|
||||||
|
_connection?.SendMessageAsync(channel, content)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
||||||
|
_connection?.GetHistoryAsync(channel)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||||
|
_connection?.GetOnlineUsersAsync(channel)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
public Task UpdateStatusAsync(UserStatus status, string? message) =>
|
||||||
|
_connection?.UpdateStatusAsync(status, message)
|
||||||
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
|
// ── Reconnect ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejoin all previously tracked channels after a reconnect.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RejoinChannelsAsync()
|
||||||
|
{
|
||||||
|
var channels = _joinedChannels.ToList();
|
||||||
|
if (channels.Count == 0 || _connection is null) return;
|
||||||
|
|
||||||
|
_joinedChannels.Clear();
|
||||||
|
|
||||||
|
foreach (var channel in channels)
|
||||||
|
{
|
||||||
|
_joinedChannels.Add(channel);
|
||||||
|
await _connection.JoinChannelAsync(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("Rejoined {Count} channel(s) after reconnect", channels.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SignalR Event Wiring ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void WireConnectionEvents(EchoHubConnection connection)
|
||||||
|
{
|
||||||
|
connection.OnMessageReceived += msg => MessageReceived?.Invoke(msg);
|
||||||
|
connection.OnUserJoined += (ch, user) => UserJoined?.Invoke(ch, user);
|
||||||
|
connection.OnUserLeft += (ch, user) => UserLeft?.Invoke(ch, user);
|
||||||
|
connection.OnUserStatusChanged += p => UserStatusChanged?.Invoke(p);
|
||||||
|
connection.OnUserKicked += (ch, user, reason) => UserKicked?.Invoke(ch, user, reason);
|
||||||
|
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
||||||
|
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
||||||
|
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
||||||
|
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
||||||
|
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
|
||||||
|
connection.OnError += msg => Error?.Invoke(msg);
|
||||||
|
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
|
||||||
|
connection.OnReconnected += () => Reconnected?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token Persistence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void HandleTokensRefreshed()
|
||||||
|
{
|
||||||
|
if (_apiClient?.RefreshToken is null) return;
|
||||||
|
var config = ConfigManager.Load();
|
||||||
|
var server = config.SavedServers.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (server is not null && server.RememberMe)
|
||||||
|
{
|
||||||
|
server.RefreshToken = _apiClient.RefreshToken;
|
||||||
|
ConfigManager.Save(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispose ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_apiClient?.Dispose();
|
||||||
|
if (_connection is not null)
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using AlwaysUpToDate;
|
using AlwaysUpToDate;
|
||||||
|
|
||||||
using EchoHub.Client.UI;
|
using EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the current user's session state (username, status, status message).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class UserSession
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public UserStatus Status { get; set; } = UserStatus.Online;
|
||||||
|
public string? StatusMessage { get; set; }
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
Username = string.Empty;
|
||||||
|
Status = UserStatus.Online;
|
||||||
|
StatusMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared color attributes for chat rendering (timestamps, system messages).
|
||||||
|
/// </summary>
|
||||||
|
public static partial class ChatColors
|
||||||
|
{
|
||||||
|
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
|
||||||
|
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
||||||
|
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
||||||
|
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
||||||
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
||||||
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
||||||
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
||||||
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
|
||||||
|
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
||||||
|
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
/// Non-mention text uses the provided default color.
|
||||||
|
/// </summary>
|
||||||
|
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
|
||||||
|
foreach (Match match in MentionRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
|
||||||
|
|
||||||
|
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"@[\w-]+")]
|
||||||
|
private static partial Regex MentionRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <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 Guid? MessageId { get; set; }
|
||||||
|
public bool IsMention { get; set; }
|
||||||
|
public string? AttachmentUrl { get; set; }
|
||||||
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public MessageType? Type { get; set; }
|
||||||
|
|
||||||
|
public ChatLine(string plainText)
|
||||||
|
{
|
||||||
|
Segments = [new ChatSegment(plainText, null)];
|
||||||
|
TextLength = plainText.GetColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatLine(List<ChatSegment> segments)
|
||||||
|
{
|
||||||
|
Segments = segments;
|
||||||
|
TextLength = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var text = segment.Text;
|
||||||
|
int chunkStart = 0;
|
||||||
|
int charPos = 0;
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
|
||||||
|
if (col + graphemeCols > width)
|
||||||
|
{
|
||||||
|
if (charPos > chunkStart)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
||||||
|
|
||||||
|
results.Add(new ChatLine(currentSegments));
|
||||||
|
currentSegments = [];
|
||||||
|
|
||||||
|
if (continuationIndent > 0)
|
||||||
|
{
|
||||||
|
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
||||||
|
col = continuationIndent;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkStart = charPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
col += graphemeCols;
|
||||||
|
charPos += grapheme.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkStart < text.Length)
|
||||||
|
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSegments.Count > 0)
|
||||||
|
results.Add(new ChatLine(currentSegments));
|
||||||
|
|
||||||
|
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
||||||
|
foreach (var wrapped in results)
|
||||||
|
{
|
||||||
|
wrapped.AttachmentUrl = AttachmentUrl;
|
||||||
|
wrapped.AttachmentFileName = AttachmentFileName;
|
||||||
|
wrapped.Type = Type;
|
||||||
|
wrapped.MessageId = MessageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a line contains printable color tags.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasColorTags(string text) =>
|
||||||
|
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all color tags from text, returning only the visible characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripColorTags(string text) =>
|
||||||
|
ColorTagRegex().Replace(text, "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a string containing printable color tags into colored segments.
|
||||||
|
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
||||||
|
/// </summary>
|
||||||
|
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
int lastIndex = 0;
|
||||||
|
Color? currentFg = null;
|
||||||
|
Color? currentBg = null;
|
||||||
|
var defaultFg = defaultAttr?.Foreground;
|
||||||
|
var defaultBg = defaultAttr?.Background ?? Color.None;
|
||||||
|
|
||||||
|
Attribute? BuildAttr()
|
||||||
|
{
|
||||||
|
if (currentFg is null && currentBg is null) return defaultAttr;
|
||||||
|
var fg = currentFg ?? defaultFg ?? Color.White;
|
||||||
|
var bg = currentBg ?? defaultBg;
|
||||||
|
return new Attribute(fg, bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in ColorTagRegex().Matches(text))
|
||||||
|
{
|
||||||
|
if (match.Index > lastIndex)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..match.Index];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.Groups[1].Success)
|
||||||
|
{
|
||||||
|
currentFg = null;
|
||||||
|
currentBg = null;
|
||||||
|
}
|
||||||
|
else if (match.Groups[2].Success)
|
||||||
|
{
|
||||||
|
var hex = match.Groups[3].Value;
|
||||||
|
var r = Convert.ToInt32(hex[..2], 16);
|
||||||
|
var g = Convert.ToInt32(hex[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(hex[4..6], 16);
|
||||||
|
if (match.Groups[2].Value == "F")
|
||||||
|
currentFg = new Color(r, g, b);
|
||||||
|
else
|
||||||
|
currentBg = new Color(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.Length)
|
||||||
|
{
|
||||||
|
var t = text[lastIndex..];
|
||||||
|
if (t.Length > 0)
|
||||||
|
segments.Add(new ChatSegment(t, BuildAttr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
||||||
|
private static partial Regex ColorTagRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for chat messages with per-segment 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 ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||||
|
|
||||||
|
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(VisualRole.Normal);
|
||||||
|
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
||||||
|
|
||||||
|
int charPos = 0;
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
foreach (var segment in chatLine.Segments)
|
||||||
|
{
|
||||||
|
var attr = segment.Color ?? normalAttr;
|
||||||
|
if (attr.Background == Color.None)
|
||||||
|
attr = attr with { Background = normalAttr.Background };
|
||||||
|
if (mentionBg.HasValue)
|
||||||
|
attr = attr with { Background = mentionBg.Value };
|
||||||
|
listView.SetAttribute(attr);
|
||||||
|
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (charPos >= viewportX && drawnChars + cols <= width)
|
||||||
|
{
|
||||||
|
listView.AddStr(grapheme);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
charPos += cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns chat message storage, formatting, and mutation.
|
||||||
|
/// Fires <see cref="MessagesChanged"/> when a channel's message list is modified
|
||||||
|
/// so the UI layer can refresh.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChatMessageManager
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||||
|
private readonly Dictionary<string, int> _channelUnread = [];
|
||||||
|
|
||||||
|
private string _currentUser = string.Empty;
|
||||||
|
private string _currentChannel = string.Empty;
|
||||||
|
private int _chatWidth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after any mutation to a channel's messages. Parameter is the channel name.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? MessagesChanged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The currently active channel (used for unread tracking and @mention detection).
|
||||||
|
/// </summary>
|
||||||
|
public string CurrentChannel
|
||||||
|
{
|
||||||
|
get => _currentChannel;
|
||||||
|
set => _currentChannel = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentUser => _currentUser;
|
||||||
|
|
||||||
|
public void SetCurrentUser(string username) => _currentUser = username;
|
||||||
|
|
||||||
|
public void SetChatWidth(int width) => _chatWidth = width;
|
||||||
|
|
||||||
|
// ── Queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public List<ChatLine>? GetMessages(string channelName)
|
||||||
|
{
|
||||||
|
return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetUnreadCount(string channelName)
|
||||||
|
{
|
||||||
|
return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearUnread(string channelName)
|
||||||
|
{
|
||||||
|
_channelUnread[channelName] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
|
||||||
|
|
||||||
|
// ── Mutations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format and store a received message. Increments unread count if not the active 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)
|
||||||
|
{
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
||||||
|
_channelUnread[message.ChannelName] = count + 1;
|
||||||
|
MessagesChanged?.Invoke(message.ChannelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a system/informational message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddSystemMessage(string channelName, string text)
|
||||||
|
{
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var textLines = text.Split('\n');
|
||||||
|
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
|
||||||
|
var indent = new string(' ', $"[{time}] ** ".Length);
|
||||||
|
for (int i = 1; i < textLines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = textLines[i].TrimEnd('\r');
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
messages.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new($"{indent}{line}", ChatColors.SystemAttr)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a status change message to a channel with colored styling.
|
||||||
|
/// </summary>
|
||||||
|
public void AddStatusMessage(string channelName, string username, string status)
|
||||||
|
{
|
||||||
|
var time = DateTimeOffset.Now.ToString("HH:mm");
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages = [];
|
||||||
|
_channelMessages[channelName] = messages;
|
||||||
|
}
|
||||||
|
messages.Add(new ChatLine(segments));
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all lines associated with a specific message ID.
|
||||||
|
/// </summary>
|
||||||
|
public void RemoveMessage(string channelName, Guid messageId)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.RemoveAll(l => l.MessageId == messageId);
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear all messages from a specific channel.
|
||||||
|
/// </summary>
|
||||||
|
public void ClearChannelMessages(string channelName)
|
||||||
|
{
|
||||||
|
if (_channelMessages.TryGetValue(channelName, out var messages))
|
||||||
|
{
|
||||||
|
messages.Clear();
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load historical messages into a channel, replacing any existing messages.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadHistory(string channelName, List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
var formatted = messages.SelectMany(FormatMessage).ToList();
|
||||||
|
_channelMessages[channelName] = formatted;
|
||||||
|
|
||||||
|
if (channelName == _currentChannel)
|
||||||
|
MessagesChanged?.Invoke(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset all message state (used on disconnect).
|
||||||
|
/// </summary>
|
||||||
|
public void ClearAll()
|
||||||
|
{
|
||||||
|
_channelMessages.Clear();
|
||||||
|
_channelUnread.Clear();
|
||||||
|
_currentChannel = string.Empty;
|
||||||
|
_currentUser = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<ChatLine> FormatMessage(MessageDto message)
|
||||||
|
{
|
||||||
|
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
||||||
|
var senderName = message.SenderUsername + ":";
|
||||||
|
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
|
||||||
|
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
|
||||||
|
switch (message.Type)
|
||||||
|
{
|
||||||
|
case MessageType.Image:
|
||||||
|
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
{
|
||||||
|
foreach (var artLine in message.Content.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
|
if (ChatLine.HasColorTags(trimmed))
|
||||||
|
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
||||||
|
else
|
||||||
|
lines.Add(new ChatLine($" {trimmed}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Audio:
|
||||||
|
var audioName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
||||||
|
audioLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
audioLine.AttachmentFileName = audioName;
|
||||||
|
audioLine.Type = MessageType.Audio;
|
||||||
|
lines.Add(audioLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.File:
|
||||||
|
var fileName = message.AttachmentFileName ?? "unknown";
|
||||||
|
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||||
|
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
||||||
|
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
||||||
|
fileLine.AttachmentUrl = message.AttachmentUrl;
|
||||||
|
fileLine.AttachmentFileName = fileName;
|
||||||
|
fileLine.Type = MessageType.File;
|
||||||
|
lines.Add(fileLine);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MessageType.Text:
|
||||||
|
default:
|
||||||
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
|
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
||||||
|
for (int i = 1; i < contentLines.Length; i++)
|
||||||
|
{
|
||||||
|
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||||
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Embeds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.MessageId = message.Id;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
||||||
|
{
|
||||||
|
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
||||||
|
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var line in lines)
|
||||||
|
line.IsMention = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, null)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
new(suffix, suffixColor)
|
||||||
|
};
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new($"[{time}] ", ChatColors.TimestampAttr),
|
||||||
|
new(senderName, senderColor),
|
||||||
|
};
|
||||||
|
segments.AddRange(ChatColors.SplitMentions(suffix));
|
||||||
|
return new ChatLine(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
const int borderCols = 2;
|
||||||
|
int indentCols = indent.GetColumns();
|
||||||
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
|
void AddTextLine(string text, Attribute? color)
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(
|
||||||
|
[
|
||||||
|
new ChatSegment(indent, null),
|
||||||
|
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||||
|
new ChatSegment(text, color)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> WordWrap(string text, int maxCols)
|
||||||
|
{
|
||||||
|
if (maxCols <= 0)
|
||||||
|
return [text];
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var currentLine = "";
|
||||||
|
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
||||||
|
if (candidate.GetColumns() <= maxCols)
|
||||||
|
{
|
||||||
|
currentLine = candidate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatFileSize(long? bytes)
|
||||||
|
{
|
||||||
|
if (bytes is null or 0)
|
||||||
|
return "?";
|
||||||
|
|
||||||
|
return bytes.Value switch
|
||||||
|
{
|
||||||
|
< 1024 => $"{bytes.Value} B",
|
||||||
|
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
|
||||||
|
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
|
||||||
|
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A colored text segment within a chat line.
|
||||||
|
/// </summary>
|
||||||
|
public record ChatSegment(string Text, Attribute? Color);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared rendering helpers for IListDataSource implementations.
|
||||||
|
/// </summary>
|
||||||
|
static class RenderHelpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
||||||
|
/// Returns the updated drawn-columns count.
|
||||||
|
/// </summary>
|
||||||
|
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
||||||
|
{
|
||||||
|
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
||||||
|
{
|
||||||
|
var cols = Math.Max(grapheme.GetColumns(), 1);
|
||||||
|
if (drawn + cols > maxWidth) break;
|
||||||
|
lv.AddStr(grapheme);
|
||||||
|
drawn += cols;
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
using System.Collections;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using EchoHub.Core.Models;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using Terminal.Gui.Text;
|
|
||||||
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 Guid? MessageId { get; set; }
|
|
||||||
public bool IsMention { get; set; }
|
|
||||||
public string? AttachmentUrl { get; set; }
|
|
||||||
public string? AttachmentFileName { get; set; }
|
|
||||||
public MessageType? Type { get; set; }
|
|
||||||
|
|
||||||
public ChatLine(string plainText)
|
|
||||||
{
|
|
||||||
Segments = [new ChatSegment(plainText, null)];
|
|
||||||
TextLength = plainText.GetColumns();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ChatLine(List<ChatSegment> segments)
|
|
||||||
{
|
|
||||||
Segments = segments;
|
|
||||||
TextLength = segments.Sum(s => s.Text.GetColumns());
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
{
|
|
||||||
var text = segment.Text;
|
|
||||||
int chunkStart = 0;
|
|
||||||
int charPos = 0;
|
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
|
||||||
{
|
|
||||||
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
|
|
||||||
if (col + graphemeCols > width)
|
|
||||||
{
|
|
||||||
if (charPos > chunkStart)
|
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
|
|
||||||
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
currentSegments = [];
|
|
||||||
|
|
||||||
if (continuationIndent > 0)
|
|
||||||
{
|
|
||||||
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
|
|
||||||
col = continuationIndent;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkStart = charPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
col += graphemeCols;
|
|
||||||
charPos += grapheme.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunkStart < text.Length)
|
|
||||||
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSegments.Count > 0)
|
|
||||||
results.Add(new ChatLine(currentSegments));
|
|
||||||
|
|
||||||
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
|
|
||||||
foreach (var wrapped in results)
|
|
||||||
{
|
|
||||||
wrapped.AttachmentUrl = AttachmentUrl;
|
|
||||||
wrapped.AttachmentFileName = AttachmentFileName;
|
|
||||||
wrapped.Type = Type;
|
|
||||||
wrapped.MessageId = MessageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns true if a line contains printable color tags.
|
|
||||||
/// </summary>
|
|
||||||
public static bool HasColorTags(string text) =>
|
|
||||||
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove all color tags from text, returning only the visible characters.
|
|
||||||
/// </summary>
|
|
||||||
public static string StripColorTags(string text) =>
|
|
||||||
ColorTagRegex().Replace(text, "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parse a string containing printable color tags into colored segments.
|
|
||||||
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
|
||||||
/// </summary>
|
|
||||||
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
int lastIndex = 0;
|
|
||||||
Color? currentFg = null;
|
|
||||||
Color? currentBg = null;
|
|
||||||
var defaultFg = defaultAttr?.Foreground;
|
|
||||||
var defaultBg = defaultAttr?.Background ?? Color.None;
|
|
||||||
|
|
||||||
Attribute? BuildAttr()
|
|
||||||
{
|
|
||||||
if (currentFg is null && currentBg is null) return defaultAttr;
|
|
||||||
var fg = currentFg ?? defaultFg ?? Color.White;
|
|
||||||
var bg = currentBg ?? defaultBg;
|
|
||||||
return new Attribute(fg, bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (Match match in ColorTagRegex().Matches(text))
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
{
|
|
||||||
var t = text[lastIndex..match.Index];
|
|
||||||
if (t.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(t, BuildAttr()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.Groups[1].Success)
|
|
||||||
{
|
|
||||||
currentFg = null;
|
|
||||||
currentBg = null;
|
|
||||||
}
|
|
||||||
else if (match.Groups[2].Success)
|
|
||||||
{
|
|
||||||
var hex = match.Groups[3].Value;
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
if (match.Groups[2].Value == "F")
|
|
||||||
currentFg = new Color(r, g, b);
|
|
||||||
else
|
|
||||||
currentBg = new Color(r, g, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
|
||||||
{
|
|
||||||
var t = text[lastIndex..];
|
|
||||||
if (t.Length > 0)
|
|
||||||
segments.Add(new ChatSegment(t, BuildAttr()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
|
||||||
private static partial Regex ColorTagRegex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for chat messages with per-segment 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 ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
|
||||||
|
|
||||||
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(VisualRole.Normal);
|
|
||||||
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
|
|
||||||
|
|
||||||
int charPos = 0;
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
foreach (var segment in chatLine.Segments)
|
|
||||||
{
|
|
||||||
var attr = segment.Color ?? normalAttr;
|
|
||||||
if (attr.Background == Color.None)
|
|
||||||
attr = attr with { Background = normalAttr.Background };
|
|
||||||
if (mentionBg.HasValue)
|
|
||||||
attr = attr with { Background = mentionBg.Value };
|
|
||||||
listView.SetAttribute(attr);
|
|
||||||
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
|
||||||
{
|
|
||||||
var cols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
if (charPos >= viewportX && drawnChars + cols <= width)
|
|
||||||
{
|
|
||||||
listView.AddStr(grapheme);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
charPos += cols;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
|
|
||||||
listView.SetAttribute(fillAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
/// Custom list data source for colored channel list rendering.
|
|
||||||
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
|
||||||
/// </summary>
|
|
||||||
public class ChannelListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<string> _channelNames = [];
|
|
||||||
private readonly Dictionary<string, int> _unreadCounts = [];
|
|
||||||
private string _activeChannel = string.Empty;
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
public int Count => _channelNames.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
|
|
||||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
|
||||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
|
||||||
{
|
|
||||||
_channelNames.Clear();
|
|
||||||
_channelNames.AddRange(channels);
|
|
||||||
_unreadCounts.Clear();
|
|
||||||
foreach (var kv in unread)
|
|
||||||
_unreadCounts[kv.Key] = kv.Value;
|
|
||||||
_activeChannel = activeChannel;
|
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _channelNames.Select(n => $"#{n}").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 name = _channelNames[item];
|
|
||||||
var isActive = name == _activeChannel;
|
|
||||||
_unreadCounts.TryGetValue(name, out var unread);
|
|
||||||
var hasUnread = unread > 0;
|
|
||||||
|
|
||||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
|
||||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
|
||||||
var prefix = isActive ? "> " : " ";
|
|
||||||
var channelText = $"#{name}";
|
|
||||||
var badge = hasUnread ? $" ({unread})" : "";
|
|
||||||
|
|
||||||
// Resolve Transparent backgrounds to the view's actual background
|
|
||||||
Attribute Resolve(Attribute attr) =>
|
|
||||||
attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
|
|
||||||
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
if (selected)
|
|
||||||
{
|
|
||||||
listView.SetAttribute(focusAttr);
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
|
||||||
|
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
|
||||||
|
|
||||||
if (hasUnread)
|
|
||||||
{
|
|
||||||
listView.SetAttribute(Resolve(BadgeAttr));
|
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fillAttr = selected ? focusAttr : normalAttr;
|
|
||||||
listView.SetAttribute(fillAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Custom list data source for the online users panel with per-user nickname colors.
|
|
||||||
/// </summary>
|
|
||||||
public class UserListSource : IListDataSource
|
|
||||||
{
|
|
||||||
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
|
||||||
public int Count => _users.Count;
|
|
||||||
public int MaxItemLength { get; private set; }
|
|
||||||
public bool SuspendCollectionChangedEvent { get; set; }
|
|
||||||
|
|
||||||
public void Update(List<(string Text, Attribute? NameColor)> users)
|
|
||||||
{
|
|
||||||
_users.Clear();
|
|
||||||
_users.AddRange(users);
|
|
||||||
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
|
|
||||||
if (!SuspendCollectionChangedEvent)
|
|
||||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsMarked(int item) => false;
|
|
||||||
public void SetMark(int item, bool value) { }
|
|
||||||
public IList ToList() => _users.Select(u => u.Text).ToList();
|
|
||||||
|
|
||||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
|
||||||
{
|
|
||||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
|
||||||
|
|
||||||
var (text, nameColor) = _users[item];
|
|
||||||
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
|
||||||
|
|
||||||
// Find where the name starts (after status icon + space + optional role badge)
|
|
||||||
// Format: "● ★Username" or "● Username"
|
|
||||||
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
|
|
||||||
int nameStart = 0;
|
|
||||||
while (nameStart < graphemes.Count)
|
|
||||||
{
|
|
||||||
var g = graphemes[nameStart];
|
|
||||||
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
|
|
||||||
break;
|
|
||||||
nameStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
int drawnChars = 0;
|
|
||||||
|
|
||||||
// Draw prefix (status icon + role badge) in normal color
|
|
||||||
listView.SetAttribute(normalAttr);
|
|
||||||
for (int i = 0; i < nameStart; i++)
|
|
||||||
{
|
|
||||||
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
|
||||||
if (drawnChars + cols > width) break;
|
|
||||||
listView.AddStr(graphemes[i]);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw name in nickname color
|
|
||||||
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
|
|
||||||
listView.SetAttribute(userAttr);
|
|
||||||
for (int i = nameStart; i < graphemes.Count; i++)
|
|
||||||
{
|
|
||||||
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
|
||||||
if (drawnChars + cols > width) break;
|
|
||||||
listView.AddStr(graphemes[i]);
|
|
||||||
drawnChars += cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill rest
|
|
||||||
listView.SetAttribute(normalAttr);
|
|
||||||
for (int i = drawnChars; i < width; i++)
|
|
||||||
listView.AddStr(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared rendering helpers for IListDataSource implementations.
|
|
||||||
/// </summary>
|
|
||||||
static class RenderHelpers
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
|
|
||||||
/// Returns the updated drawn-columns count.
|
|
||||||
/// </summary>
|
|
||||||
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
|
|
||||||
{
|
|
||||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
|
|
||||||
{
|
|
||||||
var cols = Math.Max(grapheme.GetColumns(), 1);
|
|
||||||
if (drawn + cols > maxWidth) break;
|
|
||||||
lv.AddStr(grapheme);
|
|
||||||
drawn += cols;
|
|
||||||
}
|
|
||||||
return drawn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared color attributes for chat rendering (timestamps, system messages).
|
|
||||||
/// </summary>
|
|
||||||
public static partial class ChatColors
|
|
||||||
{
|
|
||||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
|
|
||||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
|
|
||||||
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
|
|
||||||
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
|
|
||||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
|
|
||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
|
|
||||||
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
|
|
||||||
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
|
|
||||||
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
|
||||||
/// Non-mention text uses the provided default color.
|
|
||||||
/// </summary>
|
|
||||||
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
int lastIndex = 0;
|
|
||||||
|
|
||||||
foreach (Match match in MentionRegex().Matches(text))
|
|
||||||
{
|
|
||||||
if (match.Index > lastIndex)
|
|
||||||
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
|
|
||||||
|
|
||||||
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
|
|
||||||
lastIndex = match.Index + match.Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.Length)
|
|
||||||
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"@[\w-]+")]
|
|
||||||
private static partial Regex MentionRegex();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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.None);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ using Terminal.Gui.ViewBase;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public sealed class AudioPlayerDialog
|
public sealed class AudioPlayerDialog
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the connect dialog.
|
/// Result returned from the connect dialog.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Views;
|
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the profile edit dialog.
|
/// Result returned from the profile edit dialog.
|
||||||
@@ -243,32 +244,13 @@ public sealed class ProfileEditDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var color = ParseHexToTrueColor(hexColor.Trim());
|
var color = HexColorHelper.ParseHexToColor(hexColor.Trim(), Color.White);
|
||||||
preview.SetScheme(new Scheme
|
preview.SetScheme(new Scheme
|
||||||
{
|
{
|
||||||
Normal = new Attribute(color, Color.Blue)
|
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) =>
|
private static string? NullIfEmpty(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using Terminal.Gui.App;
|
using EchoHub.Client.UI.Chat;
|
||||||
using Terminal.Gui.Views;
|
using EchoHub.Client.UI.Helpers;
|
||||||
using Terminal.Gui.ViewBase;
|
|
||||||
using Terminal.Gui.Drawing;
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using Terminal.Gui.App;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.ViewBase;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Action selected by the user in their own profile dialog.
|
/// Action selected by the user in their own profile dialog.
|
||||||
@@ -111,7 +113,7 @@ public sealed class ProfileViewDialog
|
|||||||
// Color
|
// Color
|
||||||
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
|
||||||
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
|
||||||
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
if (HexColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
|
||||||
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
colorValue.SetScheme(new Scheme { Normal = colorAttr });
|
||||||
dialog.Add(colorLabel, colorValue);
|
dialog.Add(colorLabel, colorValue);
|
||||||
row++;
|
row++;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Terminal.Gui.Views;
|
|||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Result returned from the status dialog.
|
/// Result returned from the status dialog.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public sealed class UpdateConfirmDialog
|
public sealed class UpdateConfirmDialog
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ using Terminal.Gui.App;
|
|||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Dialogs;
|
||||||
|
|
||||||
public sealed class UpdateProgressDialog
|
public sealed class UpdateProgressDialog
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace EchoHub.Client.UI;
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper to parse hex colors to Terminal.Gui Attributes.
|
||||||
|
/// </summary>
|
||||||
|
public static class HexColorHelper
|
||||||
|
{
|
||||||
|
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.None);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a hex color string (with or without #) to a Terminal.Gui Color.
|
||||||
|
/// Returns <paramref name="fallback"/> if parsing fails.
|
||||||
|
/// </summary>
|
||||||
|
public static Color ParseHexToColor(string? hex, Color fallback = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hex))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
var trimmed = hex.TrimStart('#');
|
||||||
|
if (trimmed.Length != 6)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = Convert.ToInt32(trimmed[..2], 16);
|
||||||
|
var g = Convert.ToInt32(trimmed[2..4], 16);
|
||||||
|
var b = Convert.ToInt32(trimmed[4..6], 16);
|
||||||
|
return new Color(r, g, b);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for colored channel list rendering.
|
||||||
|
/// Active channel gets a > indicator, unread channels are bright with a count badge.
|
||||||
|
/// </summary>
|
||||||
|
public class ChannelListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<string> _channelNames = [];
|
||||||
|
private readonly Dictionary<string, int> _unreadCounts = [];
|
||||||
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _channelNames.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
|
||||||
|
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||||
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
|
|
||||||
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
|
||||||
|
{
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(channels);
|
||||||
|
_unreadCounts.Clear();
|
||||||
|
foreach (var kv in unread)
|
||||||
|
_unreadCounts[kv.Key] = kv.Value;
|
||||||
|
_activeChannel = activeChannel;
|
||||||
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _channelNames.Select(n => $"#{n}").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 name = _channelNames[item];
|
||||||
|
var isActive = name == _activeChannel;
|
||||||
|
_unreadCounts.TryGetValue(name, out var unread);
|
||||||
|
var hasUnread = unread > 0;
|
||||||
|
|
||||||
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
|
var prefix = isActive ? "> " : " ";
|
||||||
|
var channelText = $"#{name}";
|
||||||
|
var badge = hasUnread ? $" ({unread})" : "";
|
||||||
|
|
||||||
|
// Resolve Transparent backgrounds to the view's actual background
|
||||||
|
Attribute Resolve(Attribute attr) =>
|
||||||
|
attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
if (selected)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(focusAttr);
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix + channelText + badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
|
listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
|
||||||
|
|
||||||
|
if (hasUnread)
|
||||||
|
{
|
||||||
|
listView.SetAttribute(Resolve(BadgeAttr));
|
||||||
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillAttr = selected ? focusAttr : normalAttr;
|
||||||
|
listView.SetAttribute(fillAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
|
using Terminal.Gui.Views;
|
||||||
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI.ListSources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom list data source for the online users panel with per-user nickname colors.
|
||||||
|
/// </summary>
|
||||||
|
public class UserListSource : IListDataSource
|
||||||
|
{
|
||||||
|
private readonly List<(string Text, Attribute? NameColor)> _users = [];
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
public int Count => _users.Count;
|
||||||
|
public int MaxItemLength { get; private set; }
|
||||||
|
public bool SuspendCollectionChangedEvent { get; set; }
|
||||||
|
|
||||||
|
public void Update(List<(string Text, Attribute? NameColor)> users)
|
||||||
|
{
|
||||||
|
_users.Clear();
|
||||||
|
_users.AddRange(users);
|
||||||
|
MaxItemLength = users.Count > 0 ? users.Max(u => u.Text.GetColumns()) : 0;
|
||||||
|
if (!SuspendCollectionChangedEvent)
|
||||||
|
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMarked(int item) => false;
|
||||||
|
public void SetMark(int item, bool value) { }
|
||||||
|
public IList ToList() => _users.Select(u => u.Text).ToList();
|
||||||
|
|
||||||
|
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||||
|
{
|
||||||
|
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||||
|
|
||||||
|
var (text, nameColor) = _users[item];
|
||||||
|
var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||||
|
|
||||||
|
// Find where the name starts (after status icon + space + optional role badge)
|
||||||
|
// Format: "● ★Username" or "● Username"
|
||||||
|
var graphemes = GraphemeHelper.GetGraphemes(text).ToList();
|
||||||
|
int nameStart = 0;
|
||||||
|
while (nameStart < graphemes.Count)
|
||||||
|
{
|
||||||
|
var g = graphemes[nameStart];
|
||||||
|
if (g.Length > 0 && (char.IsLetterOrDigit(g[0]) || g[0] == '_'))
|
||||||
|
break;
|
||||||
|
nameStart++;
|
||||||
|
}
|
||||||
|
|
||||||
|
int drawnChars = 0;
|
||||||
|
|
||||||
|
// Draw prefix (status icon + role badge) in normal color
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = 0; i < nameStart; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw name in nickname color
|
||||||
|
var userAttr = selected ? normalAttr : nameColor ?? normalAttr;
|
||||||
|
listView.SetAttribute(userAttr);
|
||||||
|
for (int i = nameStart; i < graphemes.Count; i++)
|
||||||
|
{
|
||||||
|
var cols = Math.Max(graphemes[i].GetColumns(), 1);
|
||||||
|
if (drawnChars + cols > width) break;
|
||||||
|
listView.AddStr(graphemes[i]);
|
||||||
|
drawnChars += cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill rest
|
||||||
|
listView.SetAttribute(normalAttr);
|
||||||
|
for (int i = drawnChars; i < width; i++)
|
||||||
|
listView.AddStr(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using EchoHub.Client.Themes;
|
using EchoHub.Client.Themes;
|
||||||
|
using EchoHub.Client.UI.Chat;
|
||||||
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Client.UI.ListSources;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
@@ -55,13 +57,10 @@ public sealed class MainWindow : Runnable
|
|||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
|
||||||
private readonly Dictionary<string, int> _channelUnread = [];
|
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||||
private readonly ChannelListSource _channelListSource;
|
private readonly ChannelListSource _channelListSource;
|
||||||
private string _currentChannel = string.Empty;
|
private readonly ChatMessageManager _messageManager;
|
||||||
private string _currentUser = string.Empty;
|
|
||||||
private string _connectionStatus = "Disconnected";
|
private string _connectionStatus = "Disconnected";
|
||||||
private int _lastChatWidth;
|
private int _lastChatWidth;
|
||||||
|
|
||||||
@@ -130,9 +129,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnFileDownloadRequested;
|
public event Action<string, string>? OnFileDownloadRequested;
|
||||||
|
|
||||||
public MainWindow(IApplication app)
|
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||||
{
|
{
|
||||||
_app = app;
|
_app = app;
|
||||||
|
_messageManager = messageManager;
|
||||||
|
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||||
Arrangement = ViewArrangement.Fixed;
|
Arrangement = ViewArrangement.Fixed;
|
||||||
|
|
||||||
// Menu bar at the top
|
// Menu bar at the top
|
||||||
@@ -372,7 +373,7 @@ public sealed class MainWindow : Runnable
|
|||||||
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count)
|
||||||
{
|
{
|
||||||
var channelName = _channelNames[index.Value];
|
var channelName = _channelNames[index.Value];
|
||||||
if (channelName != _currentChannel)
|
if (channelName != _messageManager.CurrentChannel)
|
||||||
{
|
{
|
||||||
SwitchToChannel(channelName);
|
SwitchToChannel(channelName);
|
||||||
OnChannelSelected?.Invoke(channelName);
|
OnChannelSelected?.Invoke(channelName);
|
||||||
@@ -420,9 +421,9 @@ public sealed class MainWindow : Runnable
|
|||||||
else if (e.KeyCode == EnterKey.KeyCode)
|
else if (e.KeyCode == EnterKey.KeyCode)
|
||||||
{
|
{
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_currentChannel))
|
if (!string.IsNullOrEmpty(text) && !string.IsNullOrEmpty(_messageManager.CurrentChannel))
|
||||||
{
|
{
|
||||||
OnMessageSubmitted?.Invoke(_currentChannel, text);
|
OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text);
|
||||||
_inputField.Text = string.Empty;
|
_inputField.Text = string.Empty;
|
||||||
}
|
}
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -499,6 +500,7 @@ public sealed class MainWindow : Runnable
|
|||||||
if (newWidth > 0 && newWidth != _lastChatWidth)
|
if (newWidth > 0 && newWidth != _lastChatWidth)
|
||||||
{
|
{
|
||||||
_lastChatWidth = newWidth;
|
_lastChatWidth = newWidth;
|
||||||
|
_messageManager.SetChatWidth(newWidth);
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,125 +519,12 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private void OnMessagesChanged(string channelName)
|
||||||
/// Add a message to the specified channel's message list and refresh if it is the current channel.
|
|
||||||
/// Tracks unread count for non-active channels.
|
|
||||||
/// </summary>
|
|
||||||
public void AddMessage(MessageDto message)
|
|
||||||
{
|
{
|
||||||
var lines = FormatMessage(message);
|
if (channelName == _messageManager.CurrentChannel)
|
||||||
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();
|
RefreshMessages();
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
// Increment unread count for non-active channels
|
|
||||||
_channelUnread.TryGetValue(message.ChannelName, out var count);
|
|
||||||
_channelUnread[message.ChannelName] = count + 1;
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add a system/informational message to a channel with colored styling.
|
|
||||||
/// </summary>
|
|
||||||
public void AddSystemMessage(string channelName, string text)
|
|
||||||
{
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages = [];
|
|
||||||
_channelMessages[channelName] = messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var textLines = text.Split('\n');
|
|
||||||
|
|
||||||
// First line gets timestamp prefix
|
|
||||||
messages.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Continuation lines are indented to align
|
|
||||||
var indent = new string(' ', $"[{time}] ** ".Length);
|
|
||||||
for (int i = 1; i < textLines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = textLines[i].TrimEnd('\r');
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
||||||
messages.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new($"{indent}{line}", ChatColors.SystemAttr)
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add a status change message to a channel with colored styling.
|
|
||||||
/// </summary>
|
|
||||||
public void AddStatusMessage(string channelName, string username, string status)
|
|
||||||
{
|
|
||||||
var time = DateTimeOffset.Now.ToString("HH:mm");
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new($"** {username} is now {status}", ChatColors.SystemAttr)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages = [];
|
|
||||||
_channelMessages[channelName] = messages;
|
|
||||||
}
|
|
||||||
messages.Add(new ChatLine(segments));
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Remove all lines associated with a specific message ID.
|
|
||||||
/// </summary>
|
|
||||||
public void RemoveMessage(string channelName, Guid messageId)
|
|
||||||
{
|
|
||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages.RemoveAll(l => l.MessageId == messageId);
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear all messages from a specific channel.
|
|
||||||
/// </summary>
|
|
||||||
public void ClearChannelMessages(string channelName)
|
|
||||||
{
|
|
||||||
if (_channelMessages.TryGetValue(channelName, out var messages))
|
|
||||||
{
|
|
||||||
messages.Clear();
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -651,8 +540,6 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelNames.Add(ch.Name);
|
_channelNames.Add(ch.Name);
|
||||||
_channelTopics[ch.Name] = ch.Topic;
|
_channelTopics[ch.Name] = ch.Topic;
|
||||||
_channelPublic[ch.Name] = ch.IsPublic;
|
_channelPublic[ch.Name] = ch.IsPublic;
|
||||||
if (!_channelMessages.ContainsKey(ch.Name))
|
|
||||||
_channelMessages[ch.Name] = [];
|
|
||||||
}
|
}
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
@@ -669,8 +556,6 @@ public sealed class MainWindow : Runnable
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_channelNames.Add(channelName);
|
_channelNames.Add(channelName);
|
||||||
if (!_channelMessages.ContainsKey(channelName))
|
|
||||||
_channelMessages[channelName] = [];
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +576,7 @@ public sealed class MainWindow : Runnable
|
|||||||
public void SetChannelTopic(string channelName, string? topic)
|
public void SetChannelTopic(string channelName, string? topic)
|
||||||
{
|
{
|
||||||
_channelTopics[channelName] = topic;
|
_channelTopics[channelName] = topic;
|
||||||
if (channelName == _currentChannel)
|
if (channelName == _messageManager.CurrentChannel)
|
||||||
UpdateTopicBar();
|
UpdateTopicBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,15 +642,17 @@ public sealed class MainWindow : Runnable
|
|||||||
Write(_connectionStatus, Resolve(statusAttr));
|
Write(_connectionStatus, Resolve(statusAttr));
|
||||||
|
|
||||||
// User
|
// User
|
||||||
if (!string.IsNullOrEmpty(_currentUser))
|
var currentUser = _messageManager.CurrentUser;
|
||||||
Write($" \u2502 User: {_currentUser}", normalAttr);
|
if (!string.IsNullOrEmpty(currentUser))
|
||||||
|
Write($" \u2502 User: {currentUser}", normalAttr);
|
||||||
|
|
||||||
// Channel + type
|
// Channel + type
|
||||||
if (!string.IsNullOrEmpty(_currentChannel))
|
var currentChannel = _messageManager.CurrentChannel;
|
||||||
|
if (!string.IsNullOrEmpty(currentChannel))
|
||||||
{
|
{
|
||||||
_channelPublic.TryGetValue(_currentChannel, out var isPublic);
|
_channelPublic.TryGetValue(currentChannel, out var isPublic);
|
||||||
var typeSuffix = isPublic ? "public" : "private";
|
var typeSuffix = isPublic ? "public" : "private";
|
||||||
Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr);
|
Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining space
|
// Fill remaining space
|
||||||
@@ -781,17 +668,17 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the current user name for display in the status bar.
|
/// Set the current user name (delegates to message manager for @mention detection).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetCurrentUser(string username)
|
public void SetCurrentUser(string username)
|
||||||
{
|
{
|
||||||
_currentUser = username;
|
_messageManager.SetCurrentUser(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the current channel name.
|
/// Get the current channel name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CurrentChannel => _currentChannel;
|
public string CurrentChannel => _messageManager.CurrentChannel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all channel names that have message buffers (for broadcasting status changes).
|
/// Get all channel names that have message buffers (for broadcasting status changes).
|
||||||
@@ -803,11 +690,10 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void SwitchToChannel(string channelName)
|
public void SwitchToChannel(string channelName)
|
||||||
{
|
{
|
||||||
_currentChannel = channelName;
|
_messageManager.CurrentChannel = channelName;
|
||||||
_chatFrame.Title = $"#{channelName}";
|
_chatFrame.Title = $"#{channelName}";
|
||||||
|
|
||||||
// Reset unread count for this channel
|
_messageManager.ClearUnread(channelName);
|
||||||
_channelUnread[channelName] = 0;
|
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
|
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
@@ -820,33 +706,15 @@ public sealed class MainWindow : Runnable
|
|||||||
_channelList.SelectedItem = idx;
|
_channelList.SelectedItem = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Load historical messages into a channel, replacing any existing messages.
|
|
||||||
/// </summary>
|
|
||||||
public void LoadHistory(string channelName, List<MessageDto> messages)
|
|
||||||
{
|
|
||||||
var formatted = messages.SelectMany(FormatMessage).ToList();
|
|
||||||
_channelMessages[channelName] = formatted;
|
|
||||||
|
|
||||||
if (channelName == _currentChannel)
|
|
||||||
{
|
|
||||||
RefreshMessages();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clear all messages and channels (used on disconnect).
|
/// Clear all messages and channels (used on disconnect).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void ClearAll()
|
public void ClearAll()
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelMessages.Clear();
|
_messageManager.ClearAll();
|
||||||
_channelUnread.Clear();
|
|
||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_channelPublic.Clear();
|
_channelPublic.Clear();
|
||||||
_currentChannel = string.Empty;
|
|
||||||
_currentUser = string.Empty;
|
|
||||||
_channelListSource.Update([], [], string.Empty);
|
_channelListSource.Update([], [], string.Empty);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
@@ -868,7 +736,8 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
private void RefreshMessages()
|
private void RefreshMessages()
|
||||||
{
|
{
|
||||||
if (_channelMessages.TryGetValue(_currentChannel, out var messages))
|
var messages = _messageManager.GetMessages(_messageManager.CurrentChannel);
|
||||||
|
if (messages is not null)
|
||||||
{
|
{
|
||||||
var width = _messageList.Viewport.Width;
|
var width = _messageList.Viewport.Width;
|
||||||
|
|
||||||
@@ -906,11 +775,11 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
_channelListSource.Update(_channelNames, _channelUnread, _currentChannel);
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
var idx = _channelNames.IndexOf(_currentChannel);
|
var idx = _channelNames.IndexOf(_messageManager.CurrentChannel);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
_channelList.SelectedItem = idx;
|
_channelList.SelectedItem = idx;
|
||||||
}
|
}
|
||||||
@@ -920,7 +789,7 @@ public sealed class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateTopicBar()
|
private void UpdateTopicBar()
|
||||||
{
|
{
|
||||||
_channelTopics.TryGetValue(_currentChannel, out var topic);
|
_channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic);
|
||||||
if (!string.IsNullOrWhiteSpace(topic))
|
if (!string.IsNullOrWhiteSpace(topic))
|
||||||
{
|
{
|
||||||
_topicLabel.Text = $" Topic: {topic}";
|
_topicLabel.Text = $" Topic: {topic}";
|
||||||
@@ -980,7 +849,7 @@ public sealed class MainWindow : Runnable
|
|||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
var text = $"{statusIcon} {roleTag}{name}";
|
var text = $"{statusIcon} {roleTag}{name}";
|
||||||
var nameColor = ColorHelper.ParseHexColor(u.NicknameColor);
|
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor);
|
||||||
return (text, nameColor);
|
return (text, nameColor);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -989,234 +858,4 @@ public sealed class MainWindow : Runnable
|
|||||||
_usersFrame.Title = $"Users ({users.Count})";
|
_usersFrame.Title = $"Users ({users.Count})";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Format a message DTO into one or more display lines based on its MessageType.
|
|
||||||
/// Timestamps are dimmed and sender names are colored.
|
|
||||||
/// </summary>
|
|
||||||
private List<ChatLine> FormatMessage(MessageDto message)
|
|
||||||
{
|
|
||||||
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
|
|
||||||
var senderName = message.SenderUsername + ":";
|
|
||||||
var senderColor = ColorHelper.ParseHexColor(message.SenderNicknameColor);
|
|
||||||
|
|
||||||
var lines = new List<ChatLine>();
|
|
||||||
|
|
||||||
switch (message.Type)
|
|
||||||
{
|
|
||||||
case MessageType.Image:
|
|
||||||
lines.Add(BuildChatLine(time, senderName, senderColor, " [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'))
|
|
||||||
{
|
|
||||||
// Parse color tags from colored ASCII art
|
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
|
||||||
if (ChatLine.HasColorTags(trimmed))
|
|
||||||
lines.Add(ChatLine.FromColoredText(" " + trimmed));
|
|
||||||
else
|
|
||||||
lines.Add(new ChatLine($" {trimmed}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Audio:
|
|
||||||
var audioName = message.AttachmentFileName ?? "unknown";
|
|
||||||
var audioSize = FormatFileSize(message.AttachmentFileSize);
|
|
||||||
var audioLine = BuildChatLineColored(time, senderName, senderColor,
|
|
||||||
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
|
|
||||||
audioLine.AttachmentUrl = message.AttachmentUrl;
|
|
||||||
audioLine.AttachmentFileName = audioName;
|
|
||||||
audioLine.Type = MessageType.Audio;
|
|
||||||
lines.Add(audioLine);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.File:
|
|
||||||
var fileName = message.AttachmentFileName ?? "unknown";
|
|
||||||
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
|
||||||
var fileLine = BuildChatLineColored(time, senderName, senderColor,
|
|
||||||
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
|
|
||||||
fileLine.AttachmentUrl = message.AttachmentUrl;
|
|
||||||
fileLine.AttachmentFileName = fileName;
|
|
||||||
fileLine.Type = MessageType.File;
|
|
||||||
lines.Add(fileLine);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case MessageType.Text:
|
|
||||||
default:
|
|
||||||
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
|
||||||
var contentLines = displayContent.Split('\n');
|
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
|
||||||
// Continuation lines indented to align with first line's content
|
|
||||||
var indent = new string(' ', $"[{time}] {senderName} ".Length);
|
|
||||||
for (int i = 1; i < contentLines.Length; i++)
|
|
||||||
{
|
|
||||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render link embeds if present
|
|
||||||
if (message.Embeds is { Count: > 0 })
|
|
||||||
{
|
|
||||||
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
|
|
||||||
foreach (var embed in message.Embeds)
|
|
||||||
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag all lines with the message ID for deletion support
|
|
||||||
foreach (var line in lines)
|
|
||||||
line.MessageId = message.Id;
|
|
||||||
|
|
||||||
// Check for @mention of current user
|
|
||||||
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
|
|
||||||
{
|
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
|
||||||
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
|
|
||||||
{
|
|
||||||
foreach (var line in lines)
|
|
||||||
line.IsMention = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a chat line with a dimmed timestamp and optionally colored sender name.
|
|
||||||
/// </summary>
|
|
||||||
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
new(suffix, null)
|
|
||||||
};
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a chat line with a colored suffix (used for audio/file indicators).
|
|
||||||
/// </summary>
|
|
||||||
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
new(suffix, suffixColor)
|
|
||||||
};
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a chat line with @mention highlighting in the suffix text.
|
|
||||||
/// </summary>
|
|
||||||
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new($"[{time}] ", ChatColors.TimestampAttr),
|
|
||||||
new(senderName, senderColor),
|
|
||||||
};
|
|
||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
|
||||||
return new ChatLine(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Format a link embed as indented chat lines with a left border bar,
|
|
||||||
/// text at full width, and optional icon below (preview image).
|
|
||||||
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
|
|
||||||
/// </summary>
|
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
|
||||||
{
|
|
||||||
var lines = new List<ChatLine>();
|
|
||||||
const string border = "\u258f "; // ▏ + space
|
|
||||||
const int borderCols = 2;
|
|
||||||
int indentCols = indent.GetColumns();
|
|
||||||
int textWidth = chatWidth - indentCols - borderCols;
|
|
||||||
if (textWidth < 20) textWidth = 20;
|
|
||||||
|
|
||||||
// Helper: create a bordered text line
|
|
||||||
void AddTextLine(string text, Attribute? color)
|
|
||||||
{
|
|
||||||
lines.Add(new ChatLine(
|
|
||||||
[
|
|
||||||
new ChatSegment(indent, null),
|
|
||||||
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
|
||||||
new ChatSegment(text, color)
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Site name
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
|
||||||
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
|
||||||
|
|
||||||
// Title
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
|
||||||
{
|
|
||||||
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
|
||||||
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description (word-wrapped at full available width)
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
|
||||||
{
|
|
||||||
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
|
||||||
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
|
|
||||||
/// </summary>
|
|
||||||
private static List<string> WordWrap(string text, int maxCols)
|
|
||||||
{
|
|
||||||
if (maxCols <= 0)
|
|
||||||
return [text];
|
|
||||||
|
|
||||||
var result = new List<string>();
|
|
||||||
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var currentLine = "";
|
|
||||||
|
|
||||||
foreach (var word in words)
|
|
||||||
{
|
|
||||||
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
|
||||||
if (candidate.GetColumns() <= maxCols)
|
|
||||||
{
|
|
||||||
currentLine = candidate;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (currentLine.Length > 0)
|
|
||||||
result.Add(currentLine);
|
|
||||||
// If a single word exceeds maxCols, just add it as-is
|
|
||||||
currentLine = word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLine.Length > 0)
|
|
||||||
result.Add(currentLine);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatFileSize(long? bytes)
|
|
||||||
{
|
|
||||||
if (bytes is null or 0)
|
|
||||||
return "?";
|
|
||||||
|
|
||||||
return bytes.Value switch
|
|
||||||
{
|
|
||||||
< 1024 => $"{bytes.Value} B",
|
|
||||||
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
|
|
||||||
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
|
|
||||||
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="hue_icon.ico" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||||
@@ -27,7 +23,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<PackageIcon></PackageIcon>
|
<PackageIcon></PackageIcon>
|
||||||
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
<ApplicationIcon>..\EchoHub.Client\Assets\hue_icon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using EchoHub.Client.UI;
|
using EchoHub.Client.UI.Chat;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace EchoHub.Tests;
|
namespace EchoHub.Tests;
|
||||||
|
|||||||
Reference in New Issue
Block a user