Summary Update.

This commit is contained in:
2026-06-06 23:38:50 -04:00
parent dd75ca4b06
commit 2916d17868
30 changed files with 1231 additions and 21 deletions

View File

@@ -3,8 +3,14 @@ using System.Text;
namespace RelayClient.Crypto;
/// <summary>
/// Client-side mirror of RelayServer.Services.Crypto.E2EeHelper. Identical algorithms +
/// key formats so blobs round-trip cleanly between server and client.
/// See the server class for full algorithm details.
/// </summary>
public static class E2EeHelper
{
/// <summary>Generates a fresh RSA-2048 keypair. Called once per user on first launch and persisted via KeyStorage.</summary>
public static (string publicKey, string privateKey) GenerateRsaKeyPair()
{
using var rsa = RSA.Create(2048);
@@ -15,6 +21,11 @@ public static class E2EeHelper
);
}
/// <summary>
/// Hybrid encrypts a plaintext string for a specific recipient: fresh AES-256 key encrypts
/// the payload (AES-GCM), then RSA-OAEP-SHA256 wraps the AES key with the recipient's
/// public key. Returns base64-encoded fields ready to ship in a SocketEncryptedMessage.
/// </summary>
public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
{
byte[] aesKey = RandomNumberGenerator.GetBytes(32);
@@ -44,6 +55,11 @@ public static class E2EeHelper
};
}
/// <summary>
/// Reverse of EncryptForRecipient: RSA-decrypt the AES key with the recipient's private
/// key, then AES-GCM-decrypt the ciphertext. Throws on tampered/corrupt payloads
/// (auth tag mismatch). Returns the original UTF-8 plaintext string.
/// </summary>
public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64)
{
byte[] aesKey;
@@ -69,6 +85,7 @@ public static class E2EeHelper
}
}
/// <summary>The 4-tuple ciphertext bundle. Same shape on both client and server; matches SocketEncryptedMessage's encrypted fields.</summary>
public class EncryptedPayload
{
public required string CipherText { get; set; }

View File

@@ -1,7 +1,17 @@
namespace RelayClient.Crypto;
/// <summary>
/// Per-user RSA keypair persistence. Keys live as base64-encoded files in
/// {AppData}/keys/{username}.{public|private}.key
///
/// Plaintext on disk. For now this is fine because the only attack model is "someone else
/// has access to your filesystem" — at which point everything is compromised. A future
/// enhancement could encrypt the private key with a passphrase derived from the user's
/// password, similar to how SSH/PGP do it.
/// </summary>
public static class KeyStorage
{
/// <summary>Returns (and creates if needed) the per-app keys directory.</summary>
private static string GetKeyFolder()
{
var folder = Path.Combine(FileSystem.AppDataDirectory, "keys");
@@ -9,29 +19,34 @@ public static class KeyStorage
return folder;
}
/// <summary>Writes the base64 RSA private key to disk. Used at first-launch after GenerateRsaKeyPair.</summary>
public static void SavePrivateKey(string username, string privateKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
}
/// <summary>Writes the base64 RSA public key to disk. Sent to the server via WsAction.RegisterKey.</summary>
public static void SavePublicKey(string username, string publicKey)
{
File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey);
}
/// <summary>Reads the user's RSA private key. Used by TryDecryptAndParseContent on every inbound message.</summary>
public static string LoadPrivateKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"));
}
/// <summary>Reads the user's RSA public key. Used during the boot handshake to send to the server.</summary>
public static string LoadPublicKey(string username)
{
return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
}
/// <summary>True if BOTH halves of the user's keypair already exist on disk. False means we need to generate.</summary>
public static bool HasKeys(string username)
{
return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) &&
File.Exists(Path.Combine(GetKeyFolder(), $"{username}.public.key"));
}
}
}

View File

@@ -22,12 +22,18 @@ public static class EmbedHelper
private static readonly HashSet<string> ImageExtensions =
[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"];
/// <summary>Extracts every distinct http/https URL from message text. De-duped so multiple occurrences don't double-embed.</summary>
public static List<string> DetectUrls(string text)
{
if (string.IsNullOrWhiteSpace(text)) return [];
return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList();
}
/// <summary>
/// Dispatcher: classifies each URL and delegates to the appropriate Build* method.
/// Order matters — jump links and YouTube/Vimeo IDs are checked before the generic
/// image-extension and link-card paths so the more specific embed wins.
/// </summary>
public static List<View> BuildEmbeds(string text)
{
var views = new List<View>();
@@ -51,6 +57,10 @@ public static class EmbedHelper
return views;
}
/// <summary>
/// Decodes a base64 attachment to bytes and renders it as an inline Image. Used by
/// MainPage.BuildBubbleContent when a message has an image attachment.
/// </summary>
public static View BuildBase64ImageEmbed(string base64, string fileName)
{
try
@@ -86,6 +96,10 @@ public static class EmbedHelper
}
}
/// <summary>
/// Renders a non-image attachment as a tappable card. Tap → writes the bytes to a temp
/// file and hands off to the system handler via Launcher.OpenAsync.
/// </summary>
public static View BuildFileCard(string base64, string fileName, string mimeType)
{
var label = new Label
@@ -122,6 +136,7 @@ public static class EmbedHelper
};
}
/// <summary>Direct image URL → inline Image (loaded async by MAUI from the URI). Tap opens in browser.</summary>
private static View BuildImageEmbed(string url)
{
var image = new Image
@@ -146,6 +161,11 @@ public static class EmbedHelper
};
}
/// <summary>
/// Builds the "💬 Jump to linked message" card for relay://jump URLs. The actual tap
/// handler is wired by MainPage.WireJumpLinks because it needs access to the message
/// bubble dictionary that EmbedHelper doesn't know about.
/// </summary>
private static View BuildJumpCard(string relayUrl)
{
var label = new Label
@@ -167,9 +187,15 @@ public static class EmbedHelper
};
}
/// <summary>Attached property that stores the relay:// URL on the jump label so MainPage.WireJumpLinks can find it.</summary>
public static readonly BindableProperty JumpUrlProperty =
BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null);
/// <summary>
/// Generic URL card. Starts with just the URL itself; spawns a background task to fetch
/// OG meta tags from the page and append a title/description/preview-image when the
/// response arrives. The whole card is tappable to open the URL in the browser.
/// </summary>
private static View BuildLinkCard(string url)
{
var displayUrl = url.Length > 55 ? url[..52] + "…" : url;
@@ -246,6 +272,10 @@ public static class EmbedHelper
private sealed record OgData(string? Title, string? Description, string? ImageUrl);
/// <summary>
/// 4-second-budget HTTP GET + regex extract of og:title, og:description, og:image meta
/// tags from a page's HTML. Returns null on any failure (so the link card just stays bare).
/// </summary>
private static async Task<OgData?> FetchOgTagsAsync(string url)
{
try
@@ -283,6 +313,7 @@ public static class EmbedHelper
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
}
/// <summary>True if the URL's path ends with a known image extension. Used to choose between BuildImageEmbed and BuildLinkCard.</summary>
private static bool IsImageUrl(string url)
{
try
@@ -298,6 +329,7 @@ public static class EmbedHelper
@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>Extracts the 11-char video ID from any YouTube URL form (watch, youtu.be, embed, shorts, /v/).</summary>
private static bool TryGetYouTubeId(string url, out string id)
{
var match = YouTubePattern.Match(url);
@@ -314,6 +346,7 @@ public static class EmbedHelper
@"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>Extracts the numeric video ID from Vimeo URLs. Handles vimeo.com/{id}, /video/{id}, channels/x/{id}, groups/x/videos/{id}.</summary>
private static bool TryGetVimeoId(string url, out string id)
{
var match = VimeoPattern.Match(url);
@@ -326,6 +359,7 @@ public static class EmbedHelper
return false;
}
/// <summary>YouTube embed card. Thumbnail comes from img.youtube.com; player swaps to the youtube.com/embed/ URL on tap.</summary>
private static View BuildYouTubeCard(string url, string videoId) =>
BuildVideoCardWithEmbed(
providerLabel: "🎬 YouTube",
@@ -334,6 +368,7 @@ public static class EmbedHelper
thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg",
embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0");
/// <summary>Vimeo embed card. No thumbnail (Vimeo's API requires OAuth); placeholder stays black with a play badge until tap.</summary>
private static View BuildVimeoCard(string url, string videoId) =>
BuildVideoCardWithEmbed(
providerLabel: "🎬 Vimeo",
@@ -342,6 +377,11 @@ public static class EmbedHelper
thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder
embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1");
/// <summary>
/// The lazy-swap player. Default content is BuildThumbnailPlaceholder (cheap — no WebView
/// spawned). On tap, the ContentView's content swaps to a WebView pointing at embedUrl.
/// Means 50 videos in scrollback = 50 thumbnails, not 50 WebViews.
/// </summary>
private static View BuildVideoCardWithEmbed(
string providerLabel,
Color providerColor,
@@ -399,6 +439,10 @@ public static class EmbedHelper
};
}
/// <summary>
/// 16:9 thumbnail (or solid black if no thumb URL) with a translucent black play-badge
/// overlay. Calling onPlay swaps the parent ContentView's content to the real WebView.
/// </summary>
private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay)
{
var grid = new Grid
@@ -440,6 +484,7 @@ public static class EmbedHelper
return grid;
}
/// <summary>The actual in-client video player. WebView2 (Windows) and WebKit (mobile) both handle YouTube/Vimeo embed pages.</summary>
private static View BuildEmbeddedPlayer(string embedUrl)
{
return new WebView

View File

@@ -12,6 +12,12 @@ public static class MarkdownHelper
private static readonly Color MentionBg = Color.FromArgb("#2D2F5C");
private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23");
/// <summary>
/// The entry point. Returns either a single Label (simple inline text) or a
/// VerticalStackLayout (anything with paragraphs, code blocks, or headers).
/// First pass extracts fenced code blocks (verbatim, can span multiple lines), then
/// AppendTextSegment handles per-line headers and the inline parser.
/// </summary>
public static View Render(string markdown, double fontSize = 14)
{
if (string.IsNullOrEmpty(markdown))
@@ -37,6 +43,11 @@ public static class MarkdownHelper
return stack.Children.Count == 1 ? (View)stack.Children[0] : stack;
}
/// <summary>
/// Splits a non-code segment by newline and emits the right view per line. Headers/subtext
/// get their own labels; consecutive normal lines accumulate into a paragraph buffer so
/// they wrap naturally as one paragraph.
/// </summary>
private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize)
{
var paragraphBuffer = new StringBuilder();
@@ -94,6 +105,10 @@ public static class MarkdownHelper
FlushParagraph();
}
/// <summary>
/// Builds the dark-pane code block. If a language is specified, delegates token coloring
/// to SyntaxHighlighter and prepends a small green language label (Discord-style).
/// </summary>
private static View CreateCodeBlock(string language, string code)
{
var label = new Label
@@ -141,6 +156,7 @@ public static class MarkdownHelper
};
}
/// <summary>Bold, larger Label for # / ## / ### lines. Inline markdown still works inside (e.g. `# Hello **world**`).</summary>
private static Label CreateHeaderLabel(string text, double size)
{
var label = new Label
@@ -162,6 +178,7 @@ public static class MarkdownHelper
return label;
}
/// <summary>Smaller, grey Label for "-#" lines (Discord calls it subtext). Inherits inline markdown.</summary>
private static Label CreateSubtextLabel(string text, double size)
{
var label = new Label
@@ -190,6 +207,7 @@ public static class MarkdownHelper
return label;
}
/// <summary>Standard paragraph Label. Runs the inline parser to build a FormattedString of spans.</summary>
private static Label CreateInlineLabel(string text, double fontSize)
{
var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap };
@@ -204,6 +222,11 @@ public static class MarkdownHelper
return label;
}
/// <summary>
/// Attaches a TapGestureRecognizer that reveals every spoiler span in the label when
/// tapped once. MAUI Spans can't fire their own gesture events, so per-spoiler reveal
/// would require splitting the line into separate labels — this is the pragmatic compromise.
/// </summary>
private static void WireSpoilerTap(Label label, List<Span> spoilerSpans)
{
if (spoilerSpans.Count == 0) return;
@@ -220,6 +243,12 @@ public static class MarkdownHelper
label.GestureRecognizers.Add(tap);
}
/// <summary>
/// Single-pass character walk. For each markdown sigil (||, @, ~~, __, **, *, `), tries
/// to find a matching closer; if found, emits a styled Span and skips past. Otherwise the
/// char accumulates into a "plain" buffer that's flushed as a plain Span when the next
/// sigil hits or the string ends. Spoiler spans are registered in spoilerSpans for reveal.
/// </summary>
private static void ParseInline(string text, IList<Span> spans, double fontSize, List<Span> spoilerSpans)
{
var plain = new StringBuilder();
@@ -365,8 +394,13 @@ public static class MarkdownHelper
Flush();
}
/// <summary>Safe one-character lookahead. Returns '\0' past end-of-string.</summary>
private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0';
/// <summary>
/// Finds the next single occurrence of marker that is NOT immediately followed by
/// another marker. Used to disambiguate "*italic*" from "**bold**".
/// </summary>
private static int FindClosingSingle(string text, char marker, int start)
{
for (int i = start; i < text.Length; i++)

View File

@@ -2,21 +2,50 @@ using System.Text.RegularExpressions;
namespace RelayClient.Helpers;
/// <summary>
/// Discord-style syntax highlighting for ```lang...``` fenced code blocks. Builds a list of
/// MAUI Spans (with colors from the VS Code Dark+ palette) that the caller drops into a
/// FormattedString.
///
/// How it works:
/// - The opening fence captures an optional language tag (e.g. ```cs, ```python).
/// - Aliases resolves "cs" → "csharp", "js" → "javascript", etc.
/// - Tokenizers[lang] is a compiled regex with named groups (comment/string/number/word/…).
/// - For each match, SpanForMatch picks a colour based on which group matched + whether
/// a "word" hit a language keyword set.
///
/// Adding a new language: register an alias (if needed), a Keywords set, and a tokenizer regex.
/// </summary>
public static class SyntaxHighlighter
{
/// <summary>Fallback identifier color (light grey). Used for any token we don't recognise.</summary>
private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4");
/// <summary>Language keywords (if, for, return, etc.) — VS Code's "control flow" blue.</summary>
private static readonly Color KeywordColor = Color.FromArgb("#569CD6");
/// <summary>String literals — orange/salmon.</summary>
private static readonly Color StringColor = Color.FromArgb("#CE9178");
/// <summary>Numeric literals — soft green.</summary>
private static readonly Color NumberColor = Color.FromArgb("#B5CEA8");
/// <summary>Comments — green, rendered italic.</summary>
private static readonly Color CommentColor = Color.FromArgb("#6A9955");
/// <summary>Type names (heuristic: uppercase-start words in C#/JS/TS) — teal.</summary>
private static readonly Color TypeColor = Color.FromArgb("#4EC9B0");
/// <summary>Function names — yellow. Currently unused (we don't disambiguate function calls).</summary>
private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA");
/// <summary>Operators — same as default. Reserved for future use.</summary>
private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4");
/// <summary>HTML tag names (&lt;div&gt;, &lt;/p&gt;) — blue.</summary>
private static readonly Color TagColor = Color.FromArgb("#569CD6");
/// <summary>HTML/CSS attribute names, YAML keys, bash variables — light blue.</summary>
private static readonly Color AttrColor = Color.FromArgb("#9CDCFE");
/// <summary>Monospace font registered in MauiProgram. Used for all code-block spans.</summary>
private const string FontFamily = "AnonymousProRegular";
/// <summary>
/// Short language tags → canonical names. So users can write ```cs (instead of ```csharp),
/// ```py instead of ```python, etc. Case-insensitive.
/// </summary>
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["cs"] = "csharp",
@@ -34,6 +63,11 @@ public static class SyntaxHighlighter
["yml"] = "yaml"
};
/// <summary>
/// Per-language keyword sets. A token in a "word" match-group that hits one of these
/// gets rendered with KeywordColor. Case-sensitivity matches the language — Ordinal
/// for most languages, OrdinalIgnoreCase for SQL and CSS.
/// </summary>
private static readonly Dictionary<string, HashSet<string>> Keywords = new(StringComparer.OrdinalIgnoreCase)
{
["csharp"] = new(StringComparer.Ordinal)
@@ -97,6 +131,11 @@ public static class SyntaxHighlighter
}
};
/// <summary>
/// Per-language compiled token regex. Each pattern uses named groups (comment/string/
/// number/word/tag/attr/…) which SpanForMatch dispatches on. Initialised lazily in the
/// static constructor so the heavy regex compilation is paid once at startup.
/// </summary>
private static readonly Dictionary<string, Regex> Tokenizers = new(StringComparer.Ordinal);
static SyntaxHighlighter()
@@ -186,6 +225,11 @@ public static class SyntaxHighlighter
opts | RegexOptions.Multiline);
}
/// <summary>
/// Entry point. Walks every regex match in the code, emits plain spans for the gaps and
/// styled spans for the matches. If the language is unknown (or not specified), returns a
/// single default-colored span — code still renders in the monospace font, just no colors.
/// </summary>
public static List<Span> Highlight(string code, string? language, double fontSize)
{
var lang = Resolve(language);
@@ -215,6 +259,11 @@ public static class SyntaxHighlighter
return spans;
}
/// <summary>
/// Maps a regex Match to a colored Span by inspecting which named group succeeded. Words
/// fall through to a keyword-set lookup; in C#/JS/TS, uppercase-start words that aren't
/// keywords are treated as type names (a cheap heuristic that works surprisingly well).
/// </summary>
private static Span SpanForMatch(Match m, string lang, HashSet<string>? keywords, double fontSize)
{
if (m.Groups["comment"].Success)
@@ -285,6 +334,7 @@ public static class SyntaxHighlighter
return MakeSpan(m.Value, DefaultColor, fontSize);
}
/// <summary>Helper: build a Span with the monospace code font and the given colour + bold/italic flags.</summary>
private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false)
{
var attrs = FontAttributes.None;
@@ -301,6 +351,7 @@ public static class SyntaxHighlighter
};
}
/// <summary>Normalises a user-supplied language tag through the Aliases table. Returns null for empty/whitespace input.</summary>
private static string? Resolve(string? language)
{
if (string.IsNullOrWhiteSpace(language)) return null;

View File

@@ -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; }

View File

@@ -5,24 +5,69 @@ using WebSocketSharp;
namespace RelayClient.Services;
/// <summary>
/// The client-side WebSocket transport. Mirrors ChatSocketBehavior on the server.
///
/// Sending: typed helpers (SendGetHistory, SendRtcJoinChannel, SendEditMessage, …) build the
/// appropriate WsControlMessage or SocketEncryptedMessage and route through SendRaw. SendRaw
/// always uses synchronous _socket.Send because WebSocketSharp's SendAsync calls
/// Action.BeginInvoke internally, which throws PlatformNotSupportedException on .NET 5+.
/// Callers that need non-blocking sends (e.g. MainPage.SendMessage for image attachments)
/// wrap the call in Task.Run.
///
/// Receiving: OnMessage peeks the JSON. If it has an "Event" property → WsEventMessage (acks).
/// If it has a "Type" property → SignalType discriminator, deserialise into the right Socket*
/// type, fire the matching C# event. MainPage subscribes to these events.
///
/// Connect order matters: the first frame after the handshake is Authenticate (so the server
/// can verify the Core-issued token), then RegisterKey (so the server has our public key
/// before any encrypted message arrives), then GetServerKey + GetChannels.
/// </summary>
public sealed class RelaySocketClient
{
/// <summary>Username this socket is authenticated as. Captured at construction.</summary>
private readonly string _username;
/// <summary>The underlying WebSocketSharp client. Owned (constructed) by this class.</summary>
private readonly WebSocket _socket;
/// <summary>
/// The server's RSA public key, cached after the first GetServerKey response.
/// MainPage reads this to encrypt outbound chat payloads.
/// </summary>
public string? ServerPublicKey { get; private set; }
/// <summary>Fires for every raw incoming text frame. Mostly used for debug logging.</summary>
public event Action<string>? RawMessageReceived;
/// <summary>Fires when the server pushes a fresh channel list (initial connect or after CRUD).</summary>
public event Action<SocketChannelList>? ChannelListReceived;
/// <summary>Fires for newly-arrived chat messages (SignalType.EncryptedChat).</summary>
public event Action<SocketEncryptedMessage>? EncryptedChatReceived;
/// <summary>Fires when an existing message is edited by its author (SignalType.MessageEdited).</summary>
public event Action<SocketEncryptedMessage>? MessageEdited;
/// <summary>Fires when a message is deleted (SignalType.MessageDeleted).</summary>
public event Action<SocketMessageDeletedEvent>? MessageDeleted;
/// <summary>Fires when another user is typing in a channel.</summary>
public event Action<SocketTypingEvent>? TypingReceived;
/// <summary>Fires in response to a SendGetEditHistory request.</summary>
public event Action<SocketEditHistoryResponse>? EditHistoryReceived;
/// <summary>Fires for encrypted RTC SDP/ICE signals — RtcBridgeService forwards into the JS engine.</summary>
public event Action<SocketRtcSignalMessage>? EncryptedRtcSignalReceived;
/// <summary>Fires once when the server's public key arrives. Mainly used by tests; production reads ServerPublicKey directly.</summary>
public event Action<string>? ServerPublicKeyReceived;
/// <summary>Diagnostic logger. MainPage subscribes Console.WriteLine here.</summary>
public event Action<string>? Log;
/// <summary>Default URL points at localhost dev server. Production passes a remote URL.</summary>
public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
{
_username = username;
@@ -30,6 +75,12 @@ public sealed class RelaySocketClient
_socket.OnMessage += OnMessage;
}
/// <summary>
/// Opens the WebSocket and fires the four-step boot handshake IN ORDER:
/// Authenticate → RegisterKey → GetServerKey → GetChannels. Order matters because the
/// server uses RegisterKey to populate its session→username map (needed for permission
/// checks on subsequent messages).
/// </summary>
public void Connect()
{
_socket.Connect();
@@ -42,6 +93,7 @@ public sealed class RelaySocketClient
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
}
/// <summary>Detaches the message handler and closes the socket. Called from MainPage.OnDisappearing.</summary>
public void Disconnect()
{
_socket.OnMessage -= OnMessage;
@@ -49,24 +101,31 @@ public sealed class RelaySocketClient
_socket.Close();
}
/// <summary>Generic control-plane send. Serialises the WsControlMessage to JSON and ships it.</summary>
public void SendControlMessage(WsControlMessage message) =>
SendRaw(JsonSerializer.Serialize(message));
/// <summary>Request the message history for a channel. Server streams it back as individual EncryptedChat frames.</summary>
public void SendGetHistory(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId });
/// <summary>Tell the server we've joined a voice channel. Fires Speak permission check server-side.</summary>
public void SendRtcJoinChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId });
/// <summary>Tell the server we've left the voice channel. Idempotent server-side.</summary>
public void SendRtcLeaveChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId });
/// <summary>Notify channel peers that we're typing. Server broadcasts a SocketTypingEvent to everyone but us.</summary>
public void SendTyping(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId });
/// <summary>Request all historical versions of a message. Server replies with SocketEditHistoryResponse.</summary>
public void SendGetEditHistory(string messageId, string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId });
/// <summary>Create a new channel. Permission-gated server-side; on success the server broadcasts a fresh channel list.</summary>
public void SendCreateChannel(string name, ChannelType type, string group = "") =>
SendControlMessage(new WsControlMessage
{
@@ -76,9 +135,14 @@ public sealed class RelaySocketClient
ChannelGroup = group
});
/// <summary>Soft-delete a channel. Permission-gated server-side.</summary>
public void SendDeleteChannel(string channelId) =>
SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId });
/// <summary>
/// Send an edit for an existing message. Caller is responsible for encrypting the new
/// content (with the server's public key) before calling — same encryption shape as a new send.
/// </summary>
public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) =>
SendJson(new SocketEncryptedMessage
{
@@ -88,6 +152,7 @@ public sealed class RelaySocketClient
Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey
});
/// <summary>Request soft-delete of one of our own messages. Server checks ownership before honoring.</summary>
public void SendDeleteMessage(string messageId, string channelId) =>
SendJson(new SocketEncryptedMessage
{
@@ -95,6 +160,11 @@ public sealed class RelaySocketClient
SenderUsername = _username, ChannelId = channelId
});
/// <summary>
/// The single send pinch point. Synchronous (WebSocketSharp's SendAsync is broken on .NET 5+
/// due to Action.BeginInvoke). All exceptions are logged AND rethrown so the calling
/// Task.Run can surface them to the user via DisplayAlert.
/// </summary>
public void SendRaw(string message)
{
if (_socket.ReadyState != WebSocketState.Open)
@@ -114,8 +184,15 @@ public sealed class RelaySocketClient
}
}
/// <summary>Convenience: JSON-serialise any payload and ship it. Used for all SocketEncryptedMessage and WsControlMessage sends.</summary>
public void SendJson<T>(T payload) => SendRaw(JsonSerializer.Serialize(payload));
/// <summary>
/// WebSocketSharp callback for every incoming text frame. Peeks the JSON to decide whether
/// it's a control-plane ack (Event property) or data-plane message (Type property), then
/// fires the matching public C# event. Exceptions are caught locally so a malformed frame
/// can't drop the connection.
/// </summary>
private void OnMessage(object? sender, MessageEventArgs e)
{
RawMessageReceived?.Invoke(e.Data);

View File

@@ -6,14 +6,39 @@ using RelayShared.Services;
namespace RelayClient.Services;
/// <summary>
/// The bridge between the C# WebSocket pipe and the JavaScript WebRTC engine
/// running inside the HybridWebView (which is shown when a Voice channel is open).
///
/// Outbound (JS → C# → server): the WebView JS calls into C# via SendRtcSignal(json).
/// We deserialise to RtcSignalMessage, encrypt with the server's public key, wrap in
/// SocketRtcSignalMessage, and send through the WebSocket.
///
/// Inbound (server → C# → JS): the WebSocket fires EncryptedRtcSignalReceived. MainPage
/// hands it to HandleIncomingRtcSignalAsync, which decrypts with the user's private key
/// and calls back into JS via hybridWebView.InvokeJavaScriptAsync("testIndex", …).
///
/// JoinRtcChannel / LeaveRtcChannel just send WsAction control messages; presence tracking
/// happens server-side in RtcChannelPresenceService.
/// </summary>
public sealed class RtcBridgeService
{
/// <summary>The currently-signed-in username. Stamped onto outgoing RTC signals.</summary>
private readonly string _username;
/// <summary>The shared WebSocket to RelayServer. Outbound RTC signals ride on this.</summary>
private readonly RelaySocketClient _socket;
/// <summary>The MAUI HybridWebView that hosts the JS WebRTC engine. We push JS calls into it.</summary>
private readonly HybridWebView _hybridWebView;
/// <summary>Lazy view into MainPage._currentChannelId so we always have the current voice channel.</summary>
private readonly Func<string?> _getCurrentChannelId;
/// <summary>Diagnostic logger that surfaces messages back to the WebView UI. Used for status/error reporting.</summary>
private readonly Action<string> _sendRawToWebView;
/// <summary>Captures collaborators. MainPage constructs this once and never replaces it.</summary>
public RtcBridgeService(string username, RelaySocketClient socket, HybridWebView hybridWebView,
Func<string?> getCurrentChannelId, Action<string> sendRawToWebView)
{
@@ -24,6 +49,7 @@ public sealed class RtcBridgeService
_sendRawToWebView = sendRawToWebView;
}
/// <summary>Sends RtcJoin for the currently-selected channel. Server-side, this triggers the Speak permission check and presence registration.</summary>
public Task JoinRtcChannel()
{
var channelId = _getCurrentChannelId();
@@ -35,6 +61,7 @@ public sealed class RtcBridgeService
return Task.CompletedTask;
}
/// <summary>Sends RtcLeave for the currently-selected channel. Clears server-side voice presence so peers stop seeing us.</summary>
public void LeaveRtcChannel()
{
var channelId = _getCurrentChannelId();
@@ -45,6 +72,13 @@ public sealed class RtcBridgeService
_socket.SendRtcLeaveChannel(channelId);
}
/// <summary>
/// Called from JavaScript (via the HybridWebView bridge) when the WebRTC engine wants to
/// send an SDP offer/answer or ICE candidate to other peers. Parses the JSON, fills in
/// missing ChannelId/From, encrypts with the server's public key, ships as
/// SocketRtcSignalMessage. The server then forwards it (re-encrypted per-recipient) to
/// every other session in the same voice channel.
/// </summary>
public void SendRtcSignal(string json)
{
if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
@@ -105,6 +139,7 @@ public sealed class RtcBridgeService
}
}
/// <summary>JS bridge: returns the current voice-channel roster as JSON. Hits ServerAPI's REST endpoint, not the WebSocket.</summary>
public async Task<string> GetRtcParticipants()
{
var channelId = _getCurrentChannelId();
@@ -116,6 +151,11 @@ public sealed class RtcBridgeService
return JsonSerializer.Serialize(participants ?? []);
}
/// <summary>
/// MainPage hands incoming SocketRtcSignalMessage frames here. Filters out our own
/// frames, validates the channel scope, decrypts with the user's private key, parses to
/// RtcSignalMessage, then pushes into the JS RTC engine via SendRtcSignalToJsAsync.
/// </summary>
public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload)
{
// _sendRawToWebView("HandleIncomingRtcSignal called");
@@ -187,6 +227,10 @@ public sealed class RtcBridgeService
await SendRtcSignalToJsAsync(rtcSignal);
}
/// <summary>
/// Pushes the current username and channelId into JS globals (window.setUsername, window.setChannelId).
/// Called whenever the user switches voice channels OR the JS engine reports rtc_page_ready.
/// </summary>
public Task PushRtcContextToJsAsync()
{
MainThread.BeginInvokeOnMainThread(async () =>
@@ -201,6 +245,11 @@ public sealed class RtcBridgeService
return Task.CompletedTask;
}
/// <summary>
/// Final hop: hands a decrypted RtcSignalMessage off to the JS engine via
/// hybridWebView.InvokeJavaScriptAsync("testIndex", …). SDP strings have their newlines
/// escaped as "(rn)" because the JSON marshalling otherwise breaks them.
/// </summary>
private Task SendRtcSignalToJsAsync(RtcSignalMessage data)
{
if (data.Type == "rtc_offer" || data.Type == "rtc_answer")