Summary Update.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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 (<div>, </p>) — 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;
|
||||
|
||||
Reference in New Issue
Block a user