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; public partial class MainPage : ContentPage { public static string _username = string.Empty; private readonly RelaySocketClient _socket; private readonly RtcBridgeService _rtc; public static string? _userToken; private string? _currentChannelId; private string? _currentChannelName; private ChannelType _currentChannelType = ChannelType.Text; private readonly Dictionary> _messagesByChannel = new(); private readonly List _channels = []; private readonly Dictionary _messagesById = new(); private readonly Dictionary _messageBubbles = new(); private ChatMessage? _replyingToMessage; private string? _editingMessageId; private string? _pendingAttachmentBase64; private string? _pendingAttachmentMimeType; private string? _pendingAttachmentFileName; private CancellationTokenSource? _typingDebounce; private readonly Dictionary _typingClearTimers = new(); private bool _channelsInitialized; private readonly Dictionary _mentionCounts = new(); private static readonly Regex MentionExtract = new(@"@(\w+)", RegexOptions.Compiled); 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(); } 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 } 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(); 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); } 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"); }); } }); } 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"); } } 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; } 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(); private void CancelContext() { _replyingToMessage = null; _editingMessageId = null; ClearPendingAttachment(); MainThread.BeginInvokeOnMainThread(() => { ContextBar.IsVisible = false; ContextBarLabel.Text = string.Empty; MessageEntry.Text = string.Empty; }); } 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(); }); } 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); 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}"); } 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 } 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; } } 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 } 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; } } 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); } 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); } 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); } } 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); } } 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)); 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)); } 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)); } 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); } 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"); }); } 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; } protected override void OnDisappearing() { _socket.Disconnect(); base.OnDisappearing(); } 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); } } } 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(); }); } 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."; } private void ClearTypingIndicator() { foreach (var cts in _typingClearTimers.Values) cts.Cancel(); _typingClearTimers.Clear(); MainThread.BeginInvokeOnMainThread(() => { TypingLabel.IsVisible = false; TypingLabel.Text = string.Empty; }); } 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) { } 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); } private void RebuildBubbleContent(Border bubble, ChatMessage message) { bubble.Content = BuildBubbleContent(message); } 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; } 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); } } private void ScrollToMessage(string messageId) { if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return; MainThread.BeginInvokeOnMainThread(async () => await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true)); } private void RequestEditHistory(ChatMessage message) { if (string.IsNullOrWhiteSpace(message.MessageId)) return; _socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty); } private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e) { if (e.Message == "rtc_page_ready") { await _rtc.PushRtcContextToJsAsync(); return; } SafeSendRawToWebView($"JS β†’ C#: {e.Message}"); } private void SafeSendRawToWebView(string message) { MainThread.BeginInvokeOnMainThread(() => { try { hybridWebView.SendRawMessage(message); } catch (Exception ex) { Console.WriteLine($"[{_username}] WebView send failed: {ex.Message}"); } }); } 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; } } 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 { } }