using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Client.Services;
///
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
/// channels, the key envelope needed to unlock the room content key.
///
public sealed record JoinOutcome(List History, string? EncryptionSalt, string? WrappedRoomKey);
///
/// Thrown when joining a channel fails because a password is required or incorrect.
/// The UI catches this to prompt the user and retry.
///
public sealed class ChannelPasswordRequiredException : Exception
{
public string ChannelName { get; }
public ChannelPasswordRequiredException(string channelName, string message) : base(message)
{
ChannelName = channelName;
}
}
public sealed class EchoHubConnection : IAsyncDisposable
{
public const string LockedMessagePlaceholder =
"[encrypted — rejoin this channel with its passphrase to unlock]";
private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
private readonly RoomKeyStore _roomKeys;
public event Action? OnMessageReceived;
public event Action? OnUserJoined;
public event Action? OnUserLeft;
public event Action? OnChannelUpdated;
public event Action? OnUserStatusChanged;
public event Action? OnUserKicked;
public event Action? OnUserBanned;
public event Action? OnMessageDeleted;
public event Action? OnChannelNuked;
public event Action? OnForceDisconnect;
public event Action? OnError;
public event Action? OnConnectionStateChanged;
public event Action? OnReconnected;
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
{
_encryption = encryption;
_roomKeys = roomKeys;
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
.WithUrl(hubUrl, options =>
{
options.AccessTokenProvider = () => apiClient.GetValidTokenAsync();
})
.WithAutomaticReconnect()
.Build();
RegisterHandlers();
_connection.Reconnecting += _ =>
{
OnConnectionStateChanged?.Invoke("Reconnecting...");
return Task.CompletedTask;
};
_connection.Reconnected += _ =>
{
OnConnectionStateChanged?.Invoke("Connected");
OnReconnected?.Invoke();
return Task.CompletedTask;
};
_connection.Closed += _ =>
{
OnConnectionStateChanged?.Invoke("Disconnected");
return Task.CompletedTask;
};
}
private void RegisterHandlers()
{
_connection.On(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
OnMessageReceived?.Invoke(DecryptMessage(message));
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
{
OnUserJoined?.Invoke(channelName, username, presence);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.UserLeft), (channelName, username) =>
{
OnUserLeft?.Invoke(channelName, username);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.ChannelUpdated), channel =>
{
OnChannelUpdated?.Invoke(channel);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.UserStatusChanged), presence =>
{
OnUserStatusChanged?.Invoke(presence);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.UserKicked), (channelName, username, reason) =>
{
OnUserKicked?.Invoke(channelName, username, reason);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.UserBanned), (username, reason) =>
{
OnUserBanned?.Invoke(username, reason);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.MessageDeleted), (channelName, messageId) =>
{
OnMessageDeleted?.Invoke(channelName, messageId);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
{
OnChannelNuked?.Invoke(channelName);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.ForceDisconnect), reason =>
{
OnForceDisconnect?.Invoke(reason);
});
_connection.On(nameof(Core.Contracts.IEchoHubClient.Error), message =>
{
OnError?.Invoke(message);
});
}
public async Task ConnectAsync()
{
OnConnectionStateChanged?.Invoke("Connecting...");
await _connection.StartAsync();
OnConnectionStateChanged?.Invoke("Connected");
}
public async Task DisconnectAsync()
{
await _connection.StopAsync();
OnConnectionStateChanged?.Invoke("Disconnected");
}
public async Task JoinChannelAsync(string channelName, string? password = null)
{
var result = await _connection.InvokeAsync("JoinChannel", channelName, password);
if (!result.Success)
{
if (result.PasswordRequired)
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
}
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
}
public async Task LeaveChannelAsync(string channelName)
{
await _connection.InvokeAsync("LeaveChannel", channelName);
}
public async Task SendMessageAsync(string channelName, string content)
{
// Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey))
content = RoomCrypto.EncryptText(content, roomKey);
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
}
public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{
var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count, offset);
return DecryptMessages(messages);
}
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
{
await _connection.InvokeAsync("UpdateStatus", status, statusMessage);
}
public async Task> GetOnlineUsersAsync(string channelName)
{
return await _connection.InvokeAsync>("GetOnlineUsers", channelName);
}
private List DecryptMessages(List messages)
{
return messages.Select(DecryptMessage).ToList();
}
///
/// Strips the transport encryption, then the room layer for E2E channels.
/// Without the room key the content is replaced by a locked placeholder —
/// re-fetch history after unlocking to render it.
///
private MessageDto DecryptMessage(MessageDto message)
{
var content = _encryption.Decrypt(message.Content);
if (RoomCrypto.IsRoomCiphertext(content))
{
if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey)
&& RoomCrypto.TryDecryptText(content, roomKey, out var plaintext))
{
content = plaintext;
}
else
{
content = LockedMessagePlaceholder;
}
}
return message with { Content = content };
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
}
}