mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 07:36:03 +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:
@@ -0,0 +1,32 @@
|
||||
using Serilog;
|
||||
using Terminal.Gui.App;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Eliminates repeated Task.Run/try/catch/app.Invoke(ShowError) boilerplate.
|
||||
/// Runs async work on a background thread and routes exceptions to the UI.
|
||||
/// </summary>
|
||||
public static class AsyncRunner
|
||||
{
|
||||
public static void Run(
|
||||
IApplication app,
|
||||
Func<Task> work,
|
||||
Action<string> showError,
|
||||
string errorPrefix,
|
||||
string? logContext = null)
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await work();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "{Context} failed", logContext ?? errorPrefix);
|
||||
app.Invoke(() => showError($"{errorPrefix}: {ex.Message}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 EchoHub.Client.UI;
|
||||
using EchoHub.Client.UI.Dialogs;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user