using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using RelayClient.Crypto; using RelayClient.Helpers; using RelayClient.Services; using RelayShared.Rtc; using RelayShared.Services; namespace RelayClient; /// /// The chat shell. Owns: /// - RelaySocketClient (the WebSocket to the server) /// - RtcBridgeService (the bridge between the WS and the WebView-hosted WebRTC JS) /// - All in-memory message/channel state for the active session. /// /// Wire-up of socket events happens once in the constructor: /// ChannelListReceived → HandleChannelList (rebuilds sidebar; auto-selects #welcome on first load only) /// EncryptedChatReceived → HandleEncryptedChat (decrypt, append to channel buffer, render bubble) /// MessageEdited → HandleMessageEdited (decrypt new text, mutate ChatMessage, rebuild bubble in-place) /// MessageDeleted → HandleMessageDeleted (mark IsDeleted, rebuild bubble to tombstone) /// TypingReceived → HandleTyping (show "X is typing…", auto-clear after 3s) /// EditHistoryReceived → HandleEditHistory (decrypt every version, show in DisplayAlert) /// EncryptedRtcSignalReceived → RtcBridgeService.HandleIncomingRtcSignalAsync /// /// State dictionaries: /// _messagesByChannel: channelId → message list (per-channel scrollback) /// _messagesById : messageId → ChatMessage (O(1) lookup for edit/delete handlers) /// _messageBubbles : messageId → MAUI Border (O(1) in-place bubble update on edit/delete) /// _mentionCounts : channelId → unread @mention count (sidebar 🔔 badge) /// _typingClearTimers: username → CTS (per-user typing-indicator timeout) /// /// Bubble rendering is done by BuildBubbleContent (markdown body → MarkdownHelper.Render, /// URL embeds → EmbedHelper.BuildEmbeds, attachments → EmbedHelper.BuildBase64ImageEmbed/BuildFileCard). /// Right-click attaches platform-specific (WinUI RightTapped) context menus via /// AttachMessageContextMenu / AttachChannelContextMenu. /// public partial class MainPage : ContentPage { /// Logged-in username. Static so RelaySocketClient can read it without a back-pointer. public static string _username = string.Empty; /// The WebSocket pipe to RelayServer. private readonly RelaySocketClient _socket; /// The C#↔JS bridge for the WebView WebRTC engine. private readonly RtcBridgeService _rtc; /// Core-issued auth token, set during sign-in by ClientSession. public static string? _userToken; /// "channels:abc" — the currently-viewed channel, or null if none. private string? _currentChannelId; /// Display name of the current channel (used for the header label). private string? _currentChannelName; /// Drives view switching (messages vs RTC) on channel select. private ChannelType _currentChannelType = ChannelType.Text; /// channelId → message list. Per-channel scrollback kept in memory for the session. private readonly Dictionary> _messagesByChannel = new(); /// The full channel list as last delivered by the server (after Visibility filtering). private readonly List _channels = []; /// messageId → ChatMessage. O(1) lookup for edit/delete handlers. private readonly Dictionary _messagesById = new(); /// messageId → MAUI Border (the bubble). O(1) in-place bubble rebuild on edit/delete. private readonly Dictionary _messageBubbles = new(); /// When non-null, the next Send becomes a reply to this message. private ChatMessage? _replyingToMessage; /// When non-null, the next Send becomes an edit of this message. private string? _editingMessageId; /// Base64 of the staged file attachment. Cleared after Send or context cancel. private string? _pendingAttachmentBase64; private string? _pendingAttachmentMimeType; private string? _pendingAttachmentFileName; /// Cancels the previous "send typing event" if the user keeps typing. Debounce gate. private CancellationTokenSource? _typingDebounce; /// Per-username 3-second auto-clear timers for the typing indicator label. private readonly Dictionary _typingClearTimers = new(); /// /// True after the first channel-list arrives. Used so subsequent channel-list broadcasts /// (from create/delete) DON'T yank the user back to #welcome. /// private bool _channelsInitialized; /// channelId → unread @mention count. Drives the 🔔N sidebar badge. private readonly Dictionary _mentionCounts = new(); /// Matches `@word` (where word is letters/digits/underscore). Used at send time to extract mentions. private static readonly Regex MentionExtract = new(@"@(\w+)", RegexOptions.Compiled); /// /// Generates a fresh RSA keypair if the user has none on disk, wires every socket event, /// then opens the WebSocket. Connect() fires the auth/register-key/get-server-key/get-channels /// handshake in order before any message can be sent. /// 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); } _ = ServerAPI.setupClient(); _socket = new RelaySocketClient(_username); _rtc = new RtcBridgeService( _username, _socket, hybridWebView, () => _currentChannelId, SafeSendRawToWebView); hybridWebView.SetInvokeJavaScriptTarget(_rtc); _socket.Log += Console.WriteLine; _socket.ChannelListReceived += HandleChannelList; _socket.EncryptedChatReceived += HandleEncryptedChat; _socket.MessageEdited += HandleMessageEdited; _socket.MessageDeleted += HandleMessageDeleted; _socket.TypingReceived += HandleTyping; _socket.EditHistoryReceived += HandleEditHistory; _socket.EncryptedRtcSignalReceived += payload => MainThread.BeginInvokeOnMainThread(async () => await _rtc.HandleIncomingRtcSignalAsync(payload)); _socket.Connect(); SetupEditorKeyHandler(); SetupFileDragAndDrop(); } /// /// Windows-only: wires AllowDrop/DragOver/Drop on the messages view via the WinUI native /// UIElement. Drop pulls the first file out of the StorageItems payload and stages it via /// IngestPickedFileAsync (same path as the 📎 button). /// private void SetupFileDragAndDrop() { #if WINDOWS MessagesView.HandlerChanged += (s, e) => { if (MessagesView.Handler?.PlatformView is not Microsoft.UI.Xaml.UIElement el) return; el.AllowDrop = true; el.DragOver += (_, args) => { args.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Copy; args.DragUIOverride.Caption = "Attach to message"; args.DragUIOverride.IsCaptionVisible = true; args.DragUIOverride.IsGlyphVisible = true; args.Handled = true; }; el.Drop += async (_, args) => { args.Handled = true; try { if (!args.DataView.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems)) return; var items = await args.DataView.GetStorageItemsAsync(); var file = items.OfType().FirstOrDefault(); if (file is null) return; await IngestPickedFileAsync(file.Path, file.Name); } catch (Exception ex) { Console.WriteLine($"Drop failed: {ex.Message}"); } }; }; #endif } /// /// Windows-only: replaces the WinUI TextBox's default Enter-handling with Enter=send, /// Shift+Enter=newline. Has to set AcceptsReturn=false on the native TextBox and use /// AddHandler(handledEventsToo: true) to intercept Enter before it bubbles back up /// (the equivalent of WPF's PreviewKeyDown). /// private void SetupEditorKeyHandler() { #if WINDOWS MessageEntry.HandlerChanged += (s, e) => { if (MessageEntry.Handler?.PlatformView is not Microsoft.UI.Xaml.Controls.TextBox tb) return; // CRITICAL: with AcceptsReturn = true, the TextBox inserts the newline *before* // KeyDown fires for us, so args.Handled = true is too late. Turn it off and // handle Enter ourselves — bare Enter sends, Shift+Enter inserts \n manually. tb.AcceptsReturn = false; // PreviewKeyDown is exposed in WinUI via AddHandler with handledEventsToo: true. tb.AddHandler( Microsoft.UI.Xaml.UIElement.KeyDownEvent, new Microsoft.UI.Xaml.Input.KeyEventHandler((sender, args) => { if (args.Key != Windows.System.VirtualKey.Enter) return; var shift = Microsoft.UI.Input.InputKeyboardSource .GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift); bool shiftHeld = shift.HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); args.Handled = true; if (shiftHeld) { // Shift+Enter → insert a newline at the caret. int caret = tb.SelectionStart; var text = tb.Text ?? string.Empty; tb.Text = text.Insert(caret, Environment.NewLine); tb.SelectionStart = caret + Environment.NewLine.Length; } else { // Bare Enter → send the message. MainThread.BeginInvokeOnMainThread(SendMessage); } }), handledEventsToo: true); }; #endif } private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage(); /// /// Debounced typing-indicator fire. Resets a 400ms timer every keystroke; on timeout /// (i.e. the user paused), sends SendTyping. Prevents spamming the server with one event /// per character. /// private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e) { if (string.IsNullOrWhiteSpace(_currentChannelId)) return; _typingDebounce?.Cancel(); _typingDebounce = new CancellationTokenSource(); var token = _typingDebounce.Token; _ = Task.Run(async () => { await Task.Delay(400, token); if (!token.IsCancellationRequested) _socket.SendTyping(_currentChannelId!); }, token); } /// /// The main send path. Handles three modes: /// - Edit mode (_editingMessageId set) → encrypts new text and fires SendEditMessage. /// - New message → extracts @mentions, auto-mentions reply target, attaches any /// staged file, encrypts the ChatMessageContent JSON, sends as ClientEncryptedChat. /// /// CRITICAL: snapshots UI state to local variables BEFORE clearing the input, then runs /// the encrypt+send off the UI thread via Task.Run. Attachments can be megabytes; doing /// the encryption synchronously would freeze the UI for seconds. /// private void SendMessage() { if (!MessageEntry.IsEnabled) return; var text = MessageEntry.Text?.Trim(); if (string.IsNullOrWhiteSpace(text) && _pendingAttachmentBase64 is null) return; if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey) || string.IsNullOrWhiteSpace(_currentChannelId)) return; if (_editingMessageId is not null) { var editContent = new ChatMessageContent { Text = text ?? string.Empty }; var editEncrypted = E2EeHelper.EncryptForRecipient( JsonSerializer.Serialize(editContent), _socket.ServerPublicKey); _socket.SendEditMessage(_editingMessageId, _currentChannelId!, editEncrypted); CancelContext(); return; } var mentions = MentionExtract .Matches(text ?? string.Empty) .Select(m => m.Groups[1].Value) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (_replyingToMessage is not null && !mentions.Contains(_replyingToMessage.SenderUsername, StringComparer.OrdinalIgnoreCase)) { mentions.Add(_replyingToMessage.SenderUsername); } var content = new ChatMessageContent { Text = text ?? string.Empty, ReplyToId = _replyingToMessage?.MessageId, ReplyToSenderUsername = _replyingToMessage?.SenderUsername, ReplyPreview = _replyingToMessage is null ? null : (_replyingToMessage.Text.Length > 100 ? _replyingToMessage.Text[..100] + "…" : _replyingToMessage.Text), Mentions = mentions.Count > 0 ? mentions : null, AttachmentBase64 = _pendingAttachmentBase64, AttachmentMimeType = _pendingAttachmentMimeType, AttachmentFileName = _pendingAttachmentFileName }; var channelId = _currentChannelId!; var serverPublicKey = _socket.ServerPublicKey!; var attachmentName = _pendingAttachmentFileName; CancelContext(); ClearPendingAttachment(); MessageEntry.Text = string.Empty; _ = Task.Run(() => { try { var contentJson = JsonSerializer.Serialize(content); var encrypted = E2EeHelper.EncryptForRecipient(contentJson, serverPublicKey); _socket.SendJson(new SocketEncryptedMessage { ChannelId = channelId, Type = SignalType.ClientEncryptedChat, SenderUsername = _username, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey }); } catch (Exception ex) { Console.WriteLine($"Send failed: {ex}"); MainThread.BeginInvokeOnMainThread(async () => { await DisplayAlert("Send failed", attachmentName is null ? $"Could not send message: {ex.Message}" : $"Could not send {attachmentName}: {ex.Message}", "OK"); }); } }); } /// 📎 button click. Opens the system file picker (no filter — all files), then stages the result via IngestPickedFileAsync. private async void AttachFile_OnClicked(object? sender, EventArgs e) { try { var result = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Attach a file" }); if (result is null) return; await IngestPickedFileAsync(result.FullPath, result.FileName); } catch (Exception ex) { Console.WriteLine($"File attach failed: {ex.Message}"); await DisplayAlert("Attach failed", ex.Message, "OK"); } } /// /// Shared by the 📎 button and drag-and-drop. Reads the file, enforces the 50 MB cap, /// base64-encodes, sets _pendingAttachment* and shows the context bar with the filename /// so the user knows it's staged. /// private async Task IngestPickedFileAsync(string fullPath, string fileName) { if (!File.Exists(fullPath)) { await DisplayAlert("Attach failed", $"File not found:\n{fullPath}", "OK"); return; } var bytes = await File.ReadAllBytesAsync(fullPath); const long maxBytes = 50L * 1024 * 1024; if (bytes.Length > maxBytes) { await DisplayAlert("File too large", $"'{fileName}' is {bytes.Length / (1024.0 * 1024.0):F1} MB. " + "Attachments must be under 50 MB.", "OK"); return; } _pendingAttachmentBase64 = Convert.ToBase64String(bytes); _pendingAttachmentFileName = fileName; _pendingAttachmentMimeType = GetMimeType(fileName); MainThread.BeginInvokeOnMainThread(() => { ContextBarLabel.Text = $"📎 {fileName} attached — Send or press ✕ to remove"; ContextBar.IsVisible = true; MessageEntry.Focus(); }); } private void ClearPendingAttachment() { _pendingAttachmentBase64 = null; _pendingAttachmentMimeType = null; _pendingAttachmentFileName = null; } /// Best-effort MIME mapping by file extension. Used to set AttachmentMimeType on send. private static string GetMimeType(string fileName) { var ext = Path.GetExtension(fileName).ToLowerInvariant(); return ext switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", ".bmp" => "image/bmp", ".pdf" => "application/pdf", ".zip" => "application/zip", ".txt" => "text/plain", _ => "application/octet-stream" }; } private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext(); /// Clears reply/edit state, drops the staged attachment, hides the context bar, empties the editor. private void CancelContext() { _replyingToMessage = null; _editingMessageId = null; ClearPendingAttachment(); MainThread.BeginInvokeOnMainThread(() => { ContextBar.IsVisible = false; ContextBarLabel.Text = string.Empty; MessageEntry.Text = string.Empty; }); } /// Enters reply mode. Next Send becomes a reply to this message + auto-mentions the original sender. private void StartReply(ChatMessage message) { _replyingToMessage = message; _editingMessageId = null; var preview = message.Text.Length > 80 ? message.Text[..80] + "…" : message.Text; MainThread.BeginInvokeOnMainThread(() => { ContextBarLabel.Text = $"↩ {message.SenderUsername}: {preview}"; ContextBar.IsVisible = true; MessageEntry.Focus(); }); } /// Enters edit mode. Pre-fills the editor with the existing text; next Send fires SendEditMessage. private void StartEdit(ChatMessage message) { _editingMessageId = message.MessageId; _replyingToMessage = null; MainThread.BeginInvokeOnMainThread(() => { ContextBarLabel.Text = "✏ Editing message — Enter to save, ✕ to cancel"; ContextBar.IsVisible = true; MessageEntry.Text = message.Text; MessageEntry.Focus(); }); } private void ConfirmDelete(ChatMessage message) => _socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty); /// Copies a relay://jump/{channel}/{message} URL to the clipboard. Pasting this in chat creates a tappable "Jump to message" embed. private void CopyMessageLink(ChatMessage message) { if (string.IsNullOrWhiteSpace(message.MessageId)) return; var link = $"relay://jump/{_currentChannelId}/{message.MessageId}"; _ = Clipboard.Default.SetTextAsync(link); SafeSendRawToWebView($"Link copied: {link}"); } /// /// Wires the message-bubble context menu. On Windows: native RightTapped via HandlerChanged. /// On mobile (no right click): double-tap as the fallback gesture. /// private void AttachMessageContextMenu(Border bubble, ChatMessage message) { #if WINDOWS bubble.HandlerChanged += (s, e) => { if (bubble.Handler?.PlatformView is not Microsoft.UI.Xaml.FrameworkElement fe) return; fe.RightTapped += async (_, args) => { args.Handled = true; await ShowMessageContextMenuAsync(message); }; }; #else var longPress = new TapGestureRecognizer { NumberOfTapsRequired = 2 }; longPress.Tapped += async (_, _) => await ShowMessageContextMenuAsync(message); bubble.GestureRecognizers.Add(longPress); #endif } /// /// Builds the action sheet shown on right-click of a message: Reply + Copy Link always; /// Edit + Delete only for messages the current user owns. /// private async Task ShowMessageContextMenuAsync(ChatMessage message) { if (message.IsDeleted) return; bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase); var options = new List { "↩ Reply", "🔗 Copy Message Link" }; if (isOwn) { options.Add("✏ Edit"); options.Add("🗑 Delete"); } var action = await DisplayActionSheet(null, "Cancel", null, [.. options]); switch (action) { case "↩ Reply": StartReply(message); break; case "🔗 Copy Message Link": CopyMessageLink(message); break; case "✏ Edit": StartEdit(message); break; case "🗑 Delete": ConfirmDelete(message); break; } } /// Same gesture wiring as AttachMessageContextMenu, but for sidebar channel buttons. private void AttachChannelContextMenu(View target, ChannelItem channel) { #if WINDOWS target.HandlerChanged += (s, e) => { if (target.Handler?.PlatformView is not Microsoft.UI.Xaml.FrameworkElement fe) return; fe.RightTapped += async (_, args) => { args.Handled = true; await ShowChannelContextMenuAsync(channel); }; }; #else var longPress = new TapGestureRecognizer { NumberOfTapsRequired = 2 }; longPress.Tapped += async (_, _) => await ShowChannelContextMenuAsync(channel); target.GestureRecognizers.Add(longPress); #endif } /// Action sheet for right-click on a channel: "View Permissions" always; "Delete Channel" only if CanManage. private async Task ShowChannelContextMenuAsync(ChannelItem channel) { var options = new List { "⚙ View Permissions" }; if (channel.CanManage) options.Add("🗑 Delete Channel"); var action = await DisplayActionSheet($"#{channel.Name}", "Cancel", null, [.. options]); switch (action) { case "⚙ View Permissions": await ShowChannelPermissionsAsync(channel); break; case "🗑 Delete Channel": await ConfirmDeleteChannelAsync(channel); break; } } /// Read-only summary dialog of channel meta and the user's resolved permissions. Full per-role editing UI is a future feature. private async Task ShowChannelPermissionsAsync(ChannelItem channel) { var lines = new List { $"Channel: #{channel.Name}", $"Type: {channel.Type}", $"Read-only: {(channel.IsReadOnly ? "Yes (only admins can post)" : "No")}", "", "Working channel permissions:", " • Visibility — who can see the channel", " • Speak — who can talk (voice channels)", " • Edit — rename / reconfigure", " • Delete — remove the channel", "", "Your access here:", $" Post: {(channel.CanPost ? "Yes" : "No")}", $" Manage: {(channel.CanManage ? "Yes" : "No")}", }; await DisplayAlert("Channel Permissions", string.Join("\n", lines), "Close"); } private async Task ConfirmDeleteChannelAsync(ChannelItem channel) { bool ok = await DisplayAlert( "Delete Channel", $"Delete #{channel.Name}? This cannot be undone.", "Delete", "Cancel"); if (ok) _socket.SendDeleteChannel(channel.ChannelId); } /// "+" sidebar button. Walks the user through name → type → group, then fires SendCreateChannel. private async void AddChannel_OnClicked(object? sender, EventArgs e) { var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel", placeholder: "channel-name", maxLength: 32); if (string.IsNullOrWhiteSpace(name)) return; var typeStr = await DisplayActionSheet("Channel Type", "Cancel", null, "Text", "Voice", "Forum", "Stage", "File"); if (typeStr is null or "Cancel") return; var channelType = typeStr switch { "Voice" => ChannelType.Voice, "Forum" => ChannelType.Forum, "Stage" => ChannelType.Stage, "File" => ChannelType.File, _ => ChannelType.Text }; var group = await DisplayPromptAsync("Group / Category", "Optional group label (e.g. General):", "OK", "Skip", placeholder: "General") ?? string.Empty; _socket.SendCreateChannel( name.Trim().ToLower().Replace(" ", "-"), channelType, group); } /// /// ChannelListReceived handler. The _channelsInitialized guard ensures the auto-select /// (jump to #welcome) only happens on the very first receive — after that, the user /// stays on whatever channel they were viewing. /// private void HandleChannelList(SocketChannelList channelList) { _channels.Clear(); _channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt)); if (!_channelsInitialized) { _channelsInitialized = true; var defaultChannel = _channels .FirstOrDefault(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase)) ?? _channels.FirstOrDefault(); if (defaultChannel is null) return; _currentChannelId = defaultChannel.ChannelId; _currentChannelName = defaultChannel.Name; _currentChannelType = defaultChannel.Type; MainThread.BeginInvokeOnMainThread(async () => { ChannelLabel.Text = $"#{_currentChannelName}"; RenderChannelList(); UpdateInputForCurrentChannel(); await _rtc.PushRtcContextToJsAsync(); }); _socket.SendGetHistory(_currentChannelId); } else { MainThread.BeginInvokeOnMainThread(RenderChannelList); } } /// /// New-message handler. Filters out frames addressed to other users (delivery is per /// recipient), decrypts via TryDecryptAndParseContent, stores in both lookup dictionaries. /// If the message is for the active channel → render immediately. If it's for a different /// channel AND mentions the user → bump the sidebar 🔔 badge. /// private void HandleEncryptedChat(SocketEncryptedMessage payload) { if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; ChatMessage message; if (payload.IsDeleted) { message = new ChatMessage { MessageId = payload.MessageId, SenderUsername = payload.SenderUsername, Timestamp = DateTime.MinValue, IsDeleted = true }; } else { if (!TryDecryptAndParseContent(payload, out var content)) return; message = new ChatMessage { MessageId = payload.MessageId, SenderUsername = payload.SenderUsername, Text = content.Text, Timestamp = DateTime.Now, ReplyToId = content.ReplyToId, ReplyToSenderUsername = content.ReplyToSenderUsername, ReplyPreview = content.ReplyPreview, Mentions = content.Mentions, AttachmentBase64 = content.AttachmentBase64, AttachmentMimeType = content.AttachmentMimeType, AttachmentFileName = content.AttachmentFileName, IsEdited = payload.IsEdited }; } if (!_messagesByChannel.ContainsKey(payload.ChannelId)) _messagesByChannel[payload.ChannelId] = []; _messagesByChannel[payload.ChannelId].Add(message); if (!string.IsNullOrWhiteSpace(message.MessageId)) _messagesById[message.MessageId] = message; if (payload.ChannelId == _currentChannelId) { MainThread.BeginInvokeOnMainThread(() => RenderSingleMessage(message)); } else if (MessageMentionsMe(message) && !message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) { _mentionCounts.TryGetValue(payload.ChannelId, out var n); _mentionCounts[payload.ChannelId] = n + 1; MainThread.BeginInvokeOnMainThread(RenderChannelList); } } /// True if message.Mentions contains this user's name OR "here" OR "everyone". Drives ping badges and bubble highlight. private static bool MessageMentionsMe(ChatMessage message) => message.Mentions is not null && message.Mentions.Any(m => string.Equals(m, _username, StringComparison.OrdinalIgnoreCase) || string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) || string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase)); /// /// Edit broadcast handler. Decrypts the new content, mutates the existing ChatMessage in /// place, then rebuilds the matching Border's content via RebuildBubbleContent. No full /// re-render — only this single bubble updates. /// private void HandleMessageEdited(SocketEncryptedMessage payload) { if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; if (!TryDecryptAndParseContent(payload, out var content)) return; if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return; message.Text = content.Text; message.IsEdited = true; if (_messageBubbles.TryGetValue(payload.MessageId, out var bubble)) MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message)); } /// Tombstone handler. Flips IsDeleted on the ChatMessage and rebuilds the bubble to a "🗑 deleted" placeholder. private void HandleMessageDeleted(SocketMessageDeletedEvent payload) { if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return; message.IsDeleted = true; if (_messageBubbles.TryGetValue(payload.MessageId, out var bubble)) MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message)); } /// /// Updates the header typing label and starts (or resets) a 3-second auto-clear timer /// per typer. Ignores typing events for channels the user isn't currently viewing. /// private void HandleTyping(SocketTypingEvent payload) { if (payload.ChannelId != _currentChannelId) return; if (payload.Username.Equals(_username, StringComparison.OrdinalIgnoreCase)) return; MainThread.BeginInvokeOnMainThread(() => { TypingLabel.Text = $"{payload.Username} is typing…"; TypingLabel.IsVisible = true; }); if (_typingClearTimers.TryGetValue(payload.Username, out var existing)) existing.Cancel(); var cts = new CancellationTokenSource(); _typingClearTimers[payload.Username] = cts; _ = Task.Run(async () => { await Task.Delay(3000, cts.Token); if (!cts.Token.IsCancellationRequested) MainThread.BeginInvokeOnMainThread(() => { TypingLabel.IsVisible = false; TypingLabel.Text = string.Empty; }); }, cts.Token); } /// /// EditHistory response handler. Decrypts every prior version, formats them with timestamps, /// and dumps into a DisplayAlert. (A scrollable popup is a future polish.) /// private void HandleEditHistory(SocketEditHistoryResponse response) { var entries = new List<(string Text, DateTime At)>(); foreach (var entry in response.Entries) { try { var pk = KeyStorage.LoadPrivateKey(_username); var plainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = entry.CipherText, Nonce = entry.Nonce, Tag = entry.Tag, EncryptedKey = entry.EncryptedKey }, pk); ChatMessageContent? parsed = null; try { parsed = JsonSerializer.Deserialize(plainText); } catch { } entries.Add((parsed?.Text ?? plainText, entry.EditedAt)); } catch (Exception ex) { Console.WriteLine($"Edit history decrypt: {ex.Message}"); } } MainThread.BeginInvokeOnMainThread(async () => { if (entries.Count == 0) { await DisplayAlert("Edit History", "No previous versions found.", "OK"); return; } var text = string.Join("\n\n", entries.Select(e => $"[{e.At:g}]\n{e.Text}")); await DisplayAlert($"Edit History ({entries.Count} versions)", text, "Close"); }); } /// /// RSA-decrypt the payload using the user's private key, then JSON-parse to ChatMessageContent. /// Falls back to treating the plaintext as raw text if JSON parse fails (compat with messages /// sent before ChatMessageContent existed). /// private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content) { content = new ChatMessageContent(); string decryptedText; try { var pk = KeyStorage.LoadPrivateKey(_username); decryptedText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, pk); } catch (Exception ex) { Console.WriteLine($"[{_username}] decrypt failed: {ex.Message}"); return false; } try { content = JsonSerializer.Deserialize(decryptedText) ?? new ChatMessageContent { Text = decryptedText }; } catch { content = new ChatMessageContent { Text = decryptedText }; } return true; } /// MAUI hook: cleanly closes the WebSocket when the user navigates away from MainPage. protected override void OnDisappearing() { _socket.Disconnect(); base.OnDisappearing(); } /// /// Rebuilds the sidebar from _channels. Groups by Group label, applies the selected-channel /// highlight, adds the 🔒 suffix for read-only channels, and shows the 🔔N badge for channels /// with unread mentions. Right-click context menu is attached per row. /// private void RenderChannelList() { SidebarList.Children.Clear(); var grouped = _channels .OrderBy(c => c.CreatedAt) .GroupBy(c => string.IsNullOrWhiteSpace(c.Group) ? "Channels" : c.Group); foreach (var group in grouped) { SidebarList.Children.Add(new Label { Text = group.Key.ToUpper(), FontSize = 10, FontAttributes = FontAttributes.Bold, TextColor = Colors.Gray, Margin = new Thickness(0, 6, 0, 2) }); foreach (var channel in group) { bool isSelected = channel.ChannelId == _currentChannelId; var prefix = channel.Type switch { ChannelType.Voice => "🔊", ChannelType.Forum => "📋", ChannelType.Stage => "🎤", ChannelType.File => "📁", _ => "#" }; var lockSuffix = channel.IsReadOnly ? " 🔒" : string.Empty; _mentionCounts.TryGetValue(channel.ChannelId, out var mentionCount); var pingSuffix = (mentionCount > 0 && !isSelected) ? $" 🔔{mentionCount}" : string.Empty; var btn = new ChannelButton { Text = $"{prefix} {channel.Name}{lockSuffix}{pingSuffix}", Type = channel.Type, Group = channel.Group, HorizontalOptions = LayoutOptions.Fill, BackgroundColor = isSelected ? Color.FromArgb("#3A3A3A") : Colors.Transparent, FontAttributes = (isSelected || mentionCount > 0) ? FontAttributes.Bold : FontAttributes.None }; btn.Clicked += (_, _) => OnChannelSelected(channel); AttachChannelContextMenu(btn, channel); SidebarList.Children.Add(btn); } } } /// /// Channel button click handler. Clears stale UI state (typing indicator, reply/edit /// context), then either swaps to the RTC view (for Voice) or shows messages + requests /// history if we haven't seen it yet. Always rebuilds the sidebar at the end so the /// selected-channel highlight updates. /// private void OnChannelSelected(ChannelItem channel) { _currentChannelId = channel.ChannelId; _currentChannelName = channel.Name; _currentChannelType = channel.Type; _mentionCounts.Remove(channel.ChannelId); ClearTypingIndicator(); CancelContext(); ChannelLabel.Text = $"#{_currentChannelName}"; MainThread.BeginInvokeOnMainThread(async () => { await _rtc.PushRtcContextToJsAsync(); if (channel.Type == ChannelType.Voice) { MessagesView.IsVisible = false; RtcView.IsVisible = true; InputArea.IsVisible = false; _ = _rtc.JoinRtcChannel(); } else { if (RtcView.IsVisible) _rtc.LeaveRtcChannel(); RtcView.IsVisible = false; MessagesView.IsVisible = true; InputArea.IsVisible = true; UpdateInputForCurrentChannel(); RenderCurrentChannelMessages(); if (!_messagesByChannel.ContainsKey(channel.ChannelId)) _socket.SendGetHistory(channel.ChannelId); } RenderChannelList(); }); } /// /// Enables/disables MessageEntry+SendButton based on the server-resolved CanPost. Updates /// the placeholder so the user knows why the input is greyed out (in read-only channels). /// private void UpdateInputForCurrentChannel() { var channel = _channels.FirstOrDefault(c => c.ChannelId == _currentChannelId); if (channel is null) return; bool canPost = channel.CanPost; MessageEntry.IsEnabled = canPost; SendButton.IsEnabled = canPost; MessageEntry.Placeholder = canPost ? "Type a message… (Shift+Enter for newline)" : "This channel is read-only — you don't have permission to post here."; } /// Cancels every pending typing-clear timer and hides the typing label. Called on channel switch. private void ClearTypingIndicator() { foreach (var cts in _typingClearTimers.Values) cts.Cancel(); _typingClearTimers.Clear(); MainThread.BeginInvokeOnMainThread(() => { TypingLabel.IsVisible = false; TypingLabel.Text = string.Empty; }); } /// Full re-render of the current channel's scrollback. Called on channel switch or when history first arrives. private void RenderCurrentChannelMessages() { MessagesLayout.Children.Clear(); if (_currentChannelId is null) return; if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages)) return; foreach (var m in messages.OrderBy(m => m.Timestamp)) RenderSingleMessage(m); } private void SwapView_OnClicked(object? sender, EventArgs e) { } /// /// Renders ONE message as a Border bubble and appends it to MessagesLayout. Bubble alignment /// (left vs right) is based on isOwn. mentionsMe adds a 2px violet stroke so the user can /// spot pings while scrolling. Bubble is registered in _messageBubbles for later in-place /// updates (edit, delete). /// private async void RenderSingleMessage(ChatMessage message) { bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase); bool mentionsMe = MessageMentionsMe(message); var bubble = new Border { StrokeThickness = mentionsMe ? 2 : 1, Stroke = mentionsMe ? new SolidColorBrush(Color.FromArgb("#9EA8FF")) : null, Padding = new Thickness(10), Margin = isOwn ? new Thickness(40, 0, 0, 0) : new Thickness(0, 0, 40, 0), HorizontalOptions = isOwn ? LayoutOptions.End : LayoutOptions.Start, Content = BuildBubbleContent(message) }; AttachMessageContextMenu(bubble, message); if (!string.IsNullOrWhiteSpace(message.MessageId)) _messageBubbles[message.MessageId] = bubble; MessagesLayout.Children.Add(bubble); await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, animated: true); } /// O(1) bubble update — swaps the Border's Content for a freshly-built one. Called by edit/delete handlers. private void RebuildBubbleContent(Border bubble, ChatMessage message) { bubble.Content = BuildBubbleContent(message); } /// /// Composes a single bubble's content. Order: tombstone short-circuit → optional reply /// quote (tap = scroll to original) → sender label → MarkdownHelper.Render body → /// inline attachment (image or file card) → URL embeds → footer (timestamp + (edited)). /// Right-click menu is wired at the bubble level (in RenderSingleMessage), not here. /// private View BuildBubbleContent(ChatMessage message) { if (message.IsDeleted) { return new Label { Text = "🗑 This message was deleted.", FontAttributes = FontAttributes.Italic, TextColor = Colors.Gray, FontSize = 13 }; } var stack = new VerticalStackLayout { Spacing = 4 }; if (message.ReplyToId is not null && message.ReplyPreview is not null) { var quoteTap = new TapGestureRecognizer(); quoteTap.Tapped += (_, _) => ScrollToMessage(message.ReplyToId); var quote = new Border { StrokeThickness = 0, BackgroundColor = Color.FromArgb("#333333"), Padding = new Thickness(8, 4), Margin = new Thickness(0, 0, 0, 2), Content = new VerticalStackLayout { Spacing = 2, Children = { new Label { Text = $"↩ {message.ReplyToSenderUsername}", FontSize = 11, FontAttributes = FontAttributes.Bold, TextColor = Color.FromArgb("#9ECEFF") }, new Label { Text = message.ReplyPreview, FontSize = 12, TextColor = Colors.LightGray, LineBreakMode = LineBreakMode.TailTruncation, MaxLines = 2 } } } }; quote.GestureRecognizers.Add(quoteTap); stack.Children.Add(quote); } stack.Children.Add(new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 }); if (!string.IsNullOrWhiteSpace(message.Text)) stack.Children.Add(MarkdownHelper.Render(message.Text)); if (!string.IsNullOrWhiteSpace(message.AttachmentBase64)) { bool isImage = message.AttachmentMimeType?.StartsWith("image/") == true; stack.Children.Add(isImage ? EmbedHelper.BuildBase64ImageEmbed(message.AttachmentBase64, message.AttachmentFileName ?? "image") : EmbedHelper.BuildFileCard(message.AttachmentBase64, message.AttachmentFileName ?? "file", message.AttachmentMimeType ?? "application/octet-stream")); } foreach (var embed in EmbedHelper.BuildEmbeds(message.Text)) { WireJumpLinks(embed); stack.Children.Add(embed); } var footer = new HorizontalStackLayout { Spacing = 6 }; footer.Children.Add(new Label { Text = message.Timestamp > DateTime.MinValue ? message.Timestamp.ToString("h:mm tt") : string.Empty, FontSize = 10, TextColor = Colors.Gray }); if (message.IsEdited) { var editedLabel = new Label { Text = "(edited)", FontSize = 10, FontAttributes = FontAttributes.Italic, TextColor = Colors.Gray, TextDecorations = TextDecorations.Underline }; var editedTap = new TapGestureRecognizer(); editedTap.Tapped += (_, _) => RequestEditHistory(message); editedLabel.GestureRecognizers.Add(editedTap); footer.Children.Add(editedLabel); } stack.Children.Add(footer); return stack; } /// /// Recursively walks an embed view tree (built by EmbedHelper) and binds the tap gesture on /// any "Jump to message" label to ScrollToMessage. EmbedHelper can't do this itself because /// it doesn't know about the message-bubble dictionary that ScrollToMessage uses. /// private void WireJumpLinks(View view) { if (view is Label lbl) { var jumpUrl = (string?)lbl.GetValue(EmbedHelper.JumpUrlProperty); if (!string.IsNullOrWhiteSpace(jumpUrl)) { var m = Regex.Match(jumpUrl, @"relay://jump/[^/]+/(.+)"); if (m.Success) { var msgId = m.Groups[1].Value; var tap = new TapGestureRecognizer(); tap.Tapped += (_, _) => ScrollToMessage(msgId); lbl.GestureRecognizers.Clear(); lbl.GestureRecognizers.Add(tap); } } } else if (view is Layout layout) { foreach (var child in layout.Children.OfType()) WireJumpLinks(child); } else if (view is Border border && border.Content is View borderContent) { WireJumpLinks(borderContent); } } /// Scrolls the bubble for the given messageId into view. No-op if the bubble isn't in this channel's render set. private void ScrollToMessage(string messageId) { if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return; MainThread.BeginInvokeOnMainThread(async () => await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true)); } /// Fires SendGetEditHistory. Server replies asynchronously with SocketEditHistoryResponse → HandleEditHistory. private void RequestEditHistory(ChatMessage message) { if (string.IsNullOrWhiteSpace(message.MessageId)) return; _socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty); } /// /// HybridWebView callback when JS does HybridWebView.SendRawMessage(string). The JS RTC /// engine fires "rtc_page_ready" when it's done initialising — we use that to push the /// current username/channelId into JS context so subsequent SDP exchanges work. /// private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") { await _rtc.PushRtcContextToJsAsync(); return; } SafeSendRawToWebView($"JS → C#: {e.Message}"); } /// Sends a message into the WebView with the call dispatched onto the UI thread. Swallows + logs send errors. private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => { try { hybridWebView.SendRawMessage(message); } catch (Exception ex) { Console.WriteLine($"[{_username}] WebView send failed: {ex.Message}"); } }); } /// /// Client-side message model. Decrypted ChatMessageContent plus server-supplied metadata /// (MessageId, IsEdited, IsDeleted) and client-side state (Timestamp, mutable Text/IsEdited /// for edit handling). /// public class ChatMessage { public string MessageId { get; set; } = string.Empty; public string SenderUsername { get; set; } = string.Empty; public string Text { get; set; } = string.Empty; public DateTime Timestamp { get; set; } public string? ReplyToId { get; set; } public string? ReplyToSenderUsername { get; set; } public string? ReplyPreview { get; set; } public List? Mentions { get; set; } public string? AttachmentBase64 { get; set; } public string? AttachmentMimeType { get; set; } public string? AttachmentFileName { get; set; } public bool IsEdited { get; set; } public bool IsDeleted { get; set; } } /// Button subclass that carries the channel's Type/Group, used so the sidebar can re-render with metadata. 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 { } }