Summary Update.
This commit is contained in:
@@ -9,40 +9,103 @@ using RelayShared.Services;
|
||||
|
||||
namespace RelayClient;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
/// <summary>Logged-in username. Static so RelaySocketClient can read it without a back-pointer.</summary>
|
||||
public static string _username = string.Empty;
|
||||
|
||||
/// <summary>The WebSocket pipe to RelayServer.</summary>
|
||||
private readonly RelaySocketClient _socket;
|
||||
|
||||
/// <summary>The C#↔JS bridge for the WebView WebRTC engine.</summary>
|
||||
private readonly RtcBridgeService _rtc;
|
||||
|
||||
/// <summary>Core-issued auth token, set during sign-in by ClientSession.</summary>
|
||||
public static string? _userToken;
|
||||
|
||||
/// <summary>"channels:abc" — the currently-viewed channel, or null if none.</summary>
|
||||
private string? _currentChannelId;
|
||||
|
||||
/// <summary>Display name of the current channel (used for the header label).</summary>
|
||||
private string? _currentChannelName;
|
||||
|
||||
/// <summary>Drives view switching (messages vs RTC) on channel select.</summary>
|
||||
private ChannelType _currentChannelType = ChannelType.Text;
|
||||
|
||||
/// <summary>channelId → message list. Per-channel scrollback kept in memory for the session.</summary>
|
||||
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
|
||||
|
||||
/// <summary>The full channel list as last delivered by the server (after Visibility filtering).</summary>
|
||||
private readonly List<ChannelItem> _channels = [];
|
||||
private readonly Dictionary<string, ChatMessage> _messagesById = new();
|
||||
private readonly Dictionary<string, Border> _messageBubbles = new();
|
||||
|
||||
/// <summary>messageId → ChatMessage. O(1) lookup for edit/delete handlers.</summary>
|
||||
private readonly Dictionary<string, ChatMessage> _messagesById = new();
|
||||
|
||||
/// <summary>messageId → MAUI Border (the bubble). O(1) in-place bubble rebuild on edit/delete.</summary>
|
||||
private readonly Dictionary<string, Border> _messageBubbles = new();
|
||||
|
||||
/// <summary>When non-null, the next Send becomes a reply to this message.</summary>
|
||||
private ChatMessage? _replyingToMessage;
|
||||
private string? _editingMessageId;
|
||||
|
||||
/// <summary>When non-null, the next Send becomes an edit of this message.</summary>
|
||||
private string? _editingMessageId;
|
||||
|
||||
/// <summary>Base64 of the staged file attachment. Cleared after Send or context cancel.</summary>
|
||||
private string? _pendingAttachmentBase64;
|
||||
private string? _pendingAttachmentMimeType;
|
||||
private string? _pendingAttachmentFileName;
|
||||
|
||||
private CancellationTokenSource? _typingDebounce;
|
||||
/// <summary>Cancels the previous "send typing event" if the user keeps typing. Debounce gate.</summary>
|
||||
private CancellationTokenSource? _typingDebounce;
|
||||
|
||||
/// <summary>Per-username 3-second auto-clear timers for the typing indicator label.</summary>
|
||||
private readonly Dictionary<string, CancellationTokenSource> _typingClearTimers = new();
|
||||
|
||||
/// <summary>
|
||||
/// True after the first channel-list arrives. Used so subsequent channel-list broadcasts
|
||||
/// (from create/delete) DON'T yank the user back to #welcome.
|
||||
/// </summary>
|
||||
private bool _channelsInitialized;
|
||||
|
||||
/// <summary>channelId → unread @mention count. Drives the 🔔N sidebar badge.</summary>
|
||||
private readonly Dictionary<string, int> _mentionCounts = new();
|
||||
|
||||
/// <summary>Matches `@word` (where word is letters/digits/underscore). Used at send time to extract mentions.</summary>
|
||||
private static readonly Regex MentionExtract =
|
||||
new(@"@(\w+)", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public MainPage(string username)
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -82,6 +145,11 @@ public partial class MainPage : ContentPage
|
||||
SetupFileDragAndDrop();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private void SetupFileDragAndDrop()
|
||||
{
|
||||
#if WINDOWS
|
||||
@@ -123,6 +191,12 @@ public partial class MainPage : ContentPage
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private void SetupEditorKeyHandler()
|
||||
{
|
||||
#if WINDOWS
|
||||
@@ -170,6 +244,11 @@ public partial class MainPage : ContentPage
|
||||
|
||||
private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_currentChannelId)) return;
|
||||
@@ -186,6 +265,16 @@ public partial class MainPage : ContentPage
|
||||
}, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void SendMessage()
|
||||
{
|
||||
if (!MessageEntry.IsEnabled) return;
|
||||
@@ -274,6 +363,7 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>📎 button click. Opens the system file picker (no filter — all files), then stages the result via IngestPickedFileAsync.</summary>
|
||||
private async void AttachFile_OnClicked(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
@@ -293,6 +383,11 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task IngestPickedFileAsync(string fullPath, string fileName)
|
||||
{
|
||||
if (!File.Exists(fullPath))
|
||||
@@ -333,6 +428,7 @@ public partial class MainPage : ContentPage
|
||||
_pendingAttachmentFileName = null;
|
||||
}
|
||||
|
||||
/// <summary>Best-effort MIME mapping by file extension. Used to set AttachmentMimeType on send.</summary>
|
||||
private static string GetMimeType(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
@@ -352,6 +448,7 @@ public partial class MainPage : ContentPage
|
||||
|
||||
private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext();
|
||||
|
||||
/// <summary>Clears reply/edit state, drops the staged attachment, hides the context bar, empties the editor.</summary>
|
||||
private void CancelContext()
|
||||
{
|
||||
_replyingToMessage = null;
|
||||
@@ -366,6 +463,7 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Enters reply mode. Next Send becomes a reply to this message + auto-mentions the original sender.</summary>
|
||||
private void StartReply(ChatMessage message)
|
||||
{
|
||||
_replyingToMessage = message;
|
||||
@@ -380,6 +478,7 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Enters edit mode. Pre-fills the editor with the existing text; next Send fires SendEditMessage.</summary>
|
||||
private void StartEdit(ChatMessage message)
|
||||
{
|
||||
_editingMessageId = message.MessageId;
|
||||
@@ -397,6 +496,7 @@ public partial class MainPage : ContentPage
|
||||
private void ConfirmDelete(ChatMessage message) =>
|
||||
_socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty);
|
||||
|
||||
/// <summary>Copies a relay://jump/{channel}/{message} URL to the clipboard. Pasting this in chat creates a tappable "Jump to message" embed.</summary>
|
||||
private void CopyMessageLink(ChatMessage message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
||||
@@ -405,6 +505,10 @@ public partial class MainPage : ContentPage
|
||||
SafeSendRawToWebView($"Link copied: {link}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires the message-bubble context menu. On Windows: native RightTapped via HandlerChanged.
|
||||
/// On mobile (no right click): double-tap as the fallback gesture.
|
||||
/// </summary>
|
||||
private void AttachMessageContextMenu(Border bubble, ChatMessage message)
|
||||
{
|
||||
#if WINDOWS
|
||||
@@ -424,6 +528,10 @@ public partial class MainPage : ContentPage
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the action sheet shown on right-click of a message: Reply + Copy Link always;
|
||||
/// Edit + Delete only for messages the current user owns.
|
||||
/// </summary>
|
||||
private async Task ShowMessageContextMenuAsync(ChatMessage message)
|
||||
{
|
||||
if (message.IsDeleted) return;
|
||||
@@ -447,6 +555,7 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same gesture wiring as AttachMessageContextMenu, but for sidebar channel buttons.</summary>
|
||||
private void AttachChannelContextMenu(View target, ChannelItem channel)
|
||||
{
|
||||
#if WINDOWS
|
||||
@@ -466,6 +575,7 @@ public partial class MainPage : ContentPage
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>Action sheet for right-click on a channel: "View Permissions" always; "Delete Channel" only if CanManage.</summary>
|
||||
private async Task ShowChannelContextMenuAsync(ChannelItem channel)
|
||||
{
|
||||
var options = new List<string> { "⚙ View Permissions" };
|
||||
@@ -485,6 +595,7 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only summary dialog of channel meta and the user's resolved permissions. Full per-role editing UI is a future feature.</summary>
|
||||
private async Task ShowChannelPermissionsAsync(ChannelItem channel)
|
||||
{
|
||||
var lines = new List<string>
|
||||
@@ -517,6 +628,7 @@ public partial class MainPage : ContentPage
|
||||
if (ok) _socket.SendDeleteChannel(channel.ChannelId);
|
||||
}
|
||||
|
||||
/// <summary>"+" sidebar button. Walks the user through name → type → group, then fires SendCreateChannel.</summary>
|
||||
private async void AddChannel_OnClicked(object? sender, EventArgs e)
|
||||
{
|
||||
var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel",
|
||||
@@ -546,6 +658,11 @@ public partial class MainPage : ContentPage
|
||||
group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleChannelList(SocketChannelList channelList)
|
||||
{
|
||||
_channels.Clear();
|
||||
@@ -581,6 +698,12 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleEncryptedChat(SocketEncryptedMessage payload)
|
||||
{
|
||||
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
||||
@@ -639,6 +762,7 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True if message.Mentions contains this user's name OR "here" OR "everyone". Drives ping badges and bubble highlight.</summary>
|
||||
private static bool MessageMentionsMe(ChatMessage message) =>
|
||||
message.Mentions is not null &&
|
||||
message.Mentions.Any(m =>
|
||||
@@ -646,6 +770,11 @@ public partial class MainPage : ContentPage
|
||||
string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleMessageEdited(SocketEncryptedMessage payload)
|
||||
{
|
||||
if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
|
||||
@@ -659,6 +788,7 @@ public partial class MainPage : ContentPage
|
||||
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
||||
}
|
||||
|
||||
/// <summary>Tombstone handler. Flips IsDeleted on the ChatMessage and rebuilds the bubble to a "🗑 deleted" placeholder.</summary>
|
||||
private void HandleMessageDeleted(SocketMessageDeletedEvent payload)
|
||||
{
|
||||
if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return;
|
||||
@@ -668,6 +798,10 @@ public partial class MainPage : ContentPage
|
||||
MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void HandleTyping(SocketTypingEvent payload)
|
||||
{
|
||||
if (payload.ChannelId != _currentChannelId) return;
|
||||
@@ -697,6 +831,10 @@ public partial class MainPage : ContentPage
|
||||
}, cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditHistory response handler. Decrypts every prior version, formats them with timestamps,
|
||||
/// and dumps into a DisplayAlert. (A scrollable popup is a future polish.)
|
||||
/// </summary>
|
||||
private void HandleEditHistory(SocketEditHistoryResponse response)
|
||||
{
|
||||
var entries = new List<(string Text, DateTime At)>();
|
||||
@@ -735,6 +873,11 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content)
|
||||
{
|
||||
content = new ChatMessageContent();
|
||||
@@ -764,12 +907,18 @@ public partial class MainPage : ContentPage
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>MAUI hook: cleanly closes the WebSocket when the user navigates away from MainPage.</summary>
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
_socket.Disconnect();
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void RenderChannelList()
|
||||
{
|
||||
SidebarList.Children.Clear();
|
||||
@@ -826,6 +975,12 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void OnChannelSelected(ChannelItem channel)
|
||||
{
|
||||
_currentChannelId = channel.ChannelId;
|
||||
@@ -869,6 +1024,10 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private void UpdateInputForCurrentChannel()
|
||||
{
|
||||
var channel = _channels.FirstOrDefault(c => c.ChannelId == _currentChannelId);
|
||||
@@ -882,6 +1041,7 @@ public partial class MainPage : ContentPage
|
||||
: "This channel is read-only — you don't have permission to post here.";
|
||||
}
|
||||
|
||||
/// <summary>Cancels every pending typing-clear timer and hides the typing label. Called on channel switch.</summary>
|
||||
private void ClearTypingIndicator()
|
||||
{
|
||||
foreach (var cts in _typingClearTimers.Values) cts.Cancel();
|
||||
@@ -893,6 +1053,7 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Full re-render of the current channel's scrollback. Called on channel switch or when history first arrives.</summary>
|
||||
private void RenderCurrentChannelMessages()
|
||||
{
|
||||
MessagesLayout.Children.Clear();
|
||||
@@ -904,6 +1065,12 @@ public partial class MainPage : ContentPage
|
||||
|
||||
private void SwapView_OnClicked(object? sender, EventArgs e) { }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private async void RenderSingleMessage(ChatMessage message)
|
||||
{
|
||||
bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -929,11 +1096,18 @@ public partial class MainPage : ContentPage
|
||||
await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, animated: true);
|
||||
}
|
||||
|
||||
/// <summary>O(1) bubble update — swaps the Border's Content for a freshly-built one. Called by edit/delete handlers.</summary>
|
||||
private void RebuildBubbleContent(Border bubble, ChatMessage message)
|
||||
{
|
||||
bubble.Content = BuildBubbleContent(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private View BuildBubbleContent(ChatMessage message)
|
||||
{
|
||||
if (message.IsDeleted)
|
||||
@@ -1039,6 +1213,11 @@ public partial class MainPage : ContentPage
|
||||
return stack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void WireJumpLinks(View view)
|
||||
{
|
||||
if (view is Label lbl)
|
||||
@@ -1068,6 +1247,7 @@ public partial class MainPage : ContentPage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scrolls the bubble for the given messageId into view. No-op if the bubble isn't in this channel's render set.</summary>
|
||||
private void ScrollToMessage(string messageId)
|
||||
{
|
||||
if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return;
|
||||
@@ -1075,12 +1255,18 @@ public partial class MainPage : ContentPage
|
||||
await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true));
|
||||
}
|
||||
|
||||
/// <summary>Fires SendGetEditHistory. Server replies asynchronously with SocketEditHistoryResponse → HandleEditHistory.</summary>
|
||||
private void RequestEditHistory(ChatMessage message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
||||
_socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
|
||||
{
|
||||
if (e.Message == "rtc_page_ready")
|
||||
@@ -1091,6 +1277,7 @@ public partial class MainPage : ContentPage
|
||||
SafeSendRawToWebView($"JS → C#: {e.Message}");
|
||||
}
|
||||
|
||||
/// <summary>Sends a message into the WebView with the call dispatched onto the UI thread. Swallows + logs send errors.</summary>
|
||||
private void SafeSendRawToWebView(string message)
|
||||
{
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
@@ -1100,6 +1287,11 @@ public partial class MainPage : ContentPage
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client-side message model. Decrypted ChatMessageContent plus server-supplied metadata
|
||||
/// (MessageId, IsEdited, IsDeleted) and client-side state (Timestamp, mutable Text/IsEdited
|
||||
/// for edit handling).
|
||||
/// </summary>
|
||||
public class ChatMessage
|
||||
{
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
@@ -1117,6 +1309,7 @@ public partial class MainPage : ContentPage
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Button subclass that carries the channel's Type/Group, used so the sidebar can re-render with metadata.</summary>
|
||||
public class ChannelButton : Button
|
||||
{
|
||||
public ChannelType Type { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user