using System.Text.Json.Serialization; using RelayClient.Crypto; using RelayClient.Services; using RelayShared.Rtc; using RelayShared.Services; namespace RelayClient; public partial class MainPage : ContentPage { private readonly string _username; private readonly RelaySocketClient _socket; private readonly RtcBridgeService _rtc; private string? _currentChannelId; private string? _currentChannelName; private readonly Dictionary> _messagesByChannel = new(); private readonly List _channels = []; public MainPage(string username) { InitializeComponent(); _username = username; UserLabel.Text = $"Logged in as: {_username}"; if (!KeyStorage.HasKeys(_username)) { var keys = E2EeHelper.GenerateRsaKeyPair(); KeyStorage.SavePrivateKey(_username, keys.privateKey); KeyStorage.SavePublicKey(_username, keys.publicKey); } hybridWebView.SetInvokeJavaScriptTarget(this); ServerAPI.setupClient(); _socket = new RelaySocketClient(_username); _rtc = new RtcBridgeService( _username, _socket, hybridWebView, () => _currentChannelId, SafeSendRawToWebView ); _socket.Log += Console.WriteLine; _socket.ChannelListReceived += HandleChannelList; _socket.EncryptedChatReceived += HandleEncryptedChat; _socket.EncryptedRtcSignalReceived += payload => { MainThread.BeginInvokeOnMainThread(async () => { await _rtc.HandleIncomingRtcSignalAsync(payload); }); }; _socket.Connect(); } private void SendButton_OnClicked(object? sender, EventArgs e) { SendMessage(); } private void MessageEntry_OnCompleted(object? sender, EventArgs e) { SendMessage(); } private void SendMessage() { var text = MessageEntry.Text?.Trim(); if (string.IsNullOrWhiteSpace(text)) return; if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey)) { Console.WriteLine("Server public key not loaded yet."); return; } if (string.IsNullOrWhiteSpace(_currentChannelId)) { Console.WriteLine("No channel selected yet."); return; } var encrypted = E2EeHelper.EncryptForRecipient(text, _socket.ServerPublicKey); var payload = new SocketEncryptedMessage { ChannelId = _currentChannelId!, Type = SignalType.ClientEncryptedChat, SenderUsername = _username, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }; _socket.SendJson(payload); Console.WriteLine($"[{_username}] sent encrypted message."); MessageEntry.Text = string.Empty; MessageEntry.Focus(); } private void HandleChannelList(SocketChannelList channelList) { _channels.Clear(); _channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt)); var defaultChannel = _channels .Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase)) .OrderBy(c => c.CreatedAt) .FirstOrDefault() ?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault(); if (defaultChannel is null) return; _currentChannelId = defaultChannel.ChannelId; _currentChannelName = defaultChannel.Name; MainThread.BeginInvokeOnMainThread(async () => { ChannelLabel.Text = $"#{_currentChannelName}"; RenderChannelList(); await _rtc.PushRtcContextToJsAsync(); }); _socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}"); } private void HandleEncryptedChat(SocketEncryptedMessage payload) { if (payload.RecipientUsername != _username) return; string decryptedText; try { var privateKey = KeyStorage.LoadPrivateKey(_username); decryptedText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, privateKey ); } catch (Exception ex) { Console.WriteLine($"[{_username}] failed to decrypt chat: {ex.Message}"); return; } var message = new ChatMessage { SenderUsername = payload.SenderUsername, Text = decryptedText, Timestamp = DateTime.Now }; if (!_messagesByChannel.ContainsKey(payload.ChannelId)) _messagesByChannel[payload.ChannelId] = []; _messagesByChannel[payload.ChannelId].Add(message); if (payload.ChannelId == _currentChannelId) { MainThread.BeginInvokeOnMainThread(() => { RenderSingleMessage(message); }); } } protected override void OnDisappearing() { _socket.Disconnect(); base.OnDisappearing(); } private void RenderChannelList() { SidebarList.Children.Clear(); foreach (var channel in _channels.OrderBy(c => c.CreatedAt)) { var button = new ChannelButton { Text = $"#{channel.Name}", Type = channel.Type, Group = channel.Group }; button.Clicked += (_, _) => { _currentChannelId = channel.ChannelId; _currentChannelName = channel.Name; MainThread.BeginInvokeOnMainThread(async () => { await _rtc.PushRtcContextToJsAsync(); if (channel.Type == ChannelType.Voice) { if (!RtcView.IsVisible) SwapView(); _ = _rtc.JoinRtcChannel(); } }); ChannelLabel.Text = $"#{_currentChannelName}"; RenderCurrentChannelMessages(); if (!_messagesByChannel.ContainsKey(channel.ChannelId)) _socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}"); }; SidebarList.Children.Add(button); } } private void RenderCurrentChannelMessages() { MessagesLayout.Children.Clear(); if (_currentChannelId is null) return; if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages)) return; foreach (var message in messages.OrderBy(m => m.Timestamp)) { RenderSingleMessage(message); } } private async void RenderSingleMessage(ChatMessage message) { var isOwnMessage = message.SenderUsername == _username; var bubble = new Border { StrokeThickness = 1, Padding = 10, Margin = isOwnMessage ? new Thickness(40, 0, 0, 0) : new Thickness(0, 0, 40, 0), HorizontalOptions = isOwnMessage ? LayoutOptions.End : LayoutOptions.Start, Content = new VerticalStackLayout { Spacing = 2, Children = { new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 }, new Label { Text = message.Text, FontSize = 14 }, new Label { Text = message.Timestamp.ToString("h:mm tt"), FontSize = 10 } } } }; MessagesLayout.Children.Add(bubble); await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); } private void SwapView() { if (RtcView.IsVisible) { MessagesScrollView.IsVisible = true; RtcView.IsVisible = false; ViewSwapped.Text = "Swap to Web View"; } else { MessagesScrollView.IsVisible = false; RtcView.IsVisible = true; ViewSwapped.Text = "Swap to Message View"; } } private void SwapView_OnClicked(object? sender, EventArgs e) { SwapView(); } public Task JoinRtcChannel() { return _rtc.JoinRtcChannel(); } public void LeaveRtcChannel() { _rtc.LeaveRtcChannel(); } public void SendRtcSignal(string json) { _rtc.SendRtcSignal(json); } public Task GetRtcParticipants() { return _rtc.GetRtcParticipants(); } private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") { await _rtc.PushRtcContextToJsAsync(); return; } SafeSendRawToWebView($"JS RAW -> C#: {e.Message}"); } private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => { try { hybridWebView.SendRawMessage(message); } catch (Exception ex) { Console.WriteLine($"[{_username}] failed to send raw message to HybridWebView: {ex.Message}"); } }); } public class ChannelButton : Button { public ChannelType Type { get; set; } public string Group { get; set; } = string.Empty; } [JsonSourceGenerationOptions(WriteIndented = false)] [JsonSerializable(typeof(RtcDescription))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(IceCandidate))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(string))] internal partial class HybridJSType : JsonSerializerContext { } }