using System.Net.Http; using System.Text.RegularExpressions; namespace RelayClient.Helpers; /// /// Detects URLs in message text and builds embed views: /// • Direct image URLs → inline Image (loaded lazily from URI or base64). /// • relay:// jump links → tappable "Jump to message" card. /// • Everything else → a link card with an async OG-tag preview loaded in the background. /// public static class EmbedHelper { private static readonly Regex UrlPattern = new( @"https?://[^\s<>""]+", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex RelayJumpPattern = new( @"relay://jump/([^/]+)/(.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly HashSet ImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"]; /// Extracts every distinct http/https URL from message text. De-duped so multiple occurrences don't double-embed. public static List DetectUrls(string text) { if (string.IsNullOrWhiteSpace(text)) return []; return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList(); } /// /// 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. /// public static List BuildEmbeds(string text) { var views = new List(); foreach (var url in DetectUrls(text)) { try { if (RelayJumpPattern.IsMatch(url)) views.Add(BuildJumpCard(url)); else if (TryGetYouTubeId(url, out var ytId)) views.Add(BuildYouTubeCard(url, ytId)); else if (TryGetVimeoId(url, out var vimeoId)) views.Add(BuildVimeoCard(url, vimeoId)); else if (IsImageUrl(url)) views.Add(BuildImageEmbed(url)); else views.Add(BuildLinkCard(url)); } catch { /* never crash the UI */ } } return views; } /// /// Decodes a base64 attachment to bytes and renders it as an inline Image. Used by /// MainPage.BuildBubbleContent when a message has an image attachment. /// public static View BuildBase64ImageEmbed(string base64, string fileName) { try { var bytes = Convert.FromBase64String(base64); var source = ImageSource.FromStream(() => new MemoryStream(bytes)); var image = new Image { Source = source, Aspect = Aspect.AspectFit, WidthRequest = 400, MaximumHeightRequest = 300, HorizontalOptions = LayoutOptions.Start }; return new Border { StrokeThickness = 1, Padding = new Thickness(4), Margin = new Thickness(0, 4, 0, 0), Content = image }; } catch { return new Label { Text = $"⚠ Could not render image: {fileName}", FontSize = 12, TextColor = Colors.Gray }; } } /// /// 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. /// public static View BuildFileCard(string base64, string fileName, string mimeType) { var label = new Label { Text = $"📎 {fileName}", FontSize = 13, TextColor = Color.FromArgb("#5DA8FF"), TextDecorations = TextDecorations.Underline }; var tap = new TapGestureRecognizer(); tap.Tapped += async (_, _) => { try { var bytes = Convert.FromBase64String(base64); var tempPath = Path.Combine(Path.GetTempPath(), fileName); await File.WriteAllBytesAsync(tempPath, bytes); await Launcher.OpenAsync(new OpenFileRequest { File = new ReadOnlyFile(tempPath) }); } catch { /* ignore launch errors */ } }; label.GestureRecognizers.Add(tap); return new Border { StrokeThickness = 1, Padding = new Thickness(8, 6), Margin = new Thickness(0, 4, 0, 0), Content = label }; } /// Direct image URL → inline Image (loaded async by MAUI from the URI). Tap opens in browser. private static View BuildImageEmbed(string url) { var image = new Image { Source = ImageSource.FromUri(new Uri(url)), Aspect = Aspect.AspectFit, WidthRequest = 400, MaximumHeightRequest = 300, HorizontalOptions = LayoutOptions.Start }; var tap = new TapGestureRecognizer(); tap.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(url)); image.GestureRecognizers.Add(tap); return new Border { StrokeThickness = 1, Padding = new Thickness(4), Margin = new Thickness(0, 4, 0, 0), Content = image }; } /// /// 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. /// private static View BuildJumpCard(string relayUrl) { var label = new Label { Text = "💬 Jump to linked message", FontSize = 12, TextColor = Color.FromArgb("#9ECEFF"), TextDecorations = TextDecorations.Underline }; label.SetValue(JumpUrlProperty, relayUrl); return new Border { StrokeThickness = 1, Padding = new Thickness(8, 4), Margin = new Thickness(0, 4, 0, 0), Content = label }; } /// Attached property that stores the relay:// URL on the jump label so MainPage.WireJumpLinks can find it. public static readonly BindableProperty JumpUrlProperty = BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null); /// /// 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. /// private static View BuildLinkCard(string url) { var displayUrl = url.Length > 55 ? url[..52] + "…" : url; var card = new VerticalStackLayout { Spacing = 4 }; var urlLabel = new Label { Text = "🔗 " + displayUrl, FontSize = 12, TextColor = Color.FromArgb("#5DA8FF"), TextDecorations = TextDecorations.Underline, LineBreakMode = LineBreakMode.TailTruncation }; var tapUrl = new TapGestureRecognizer(); tapUrl.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(url)); urlLabel.GestureRecognizers.Add(tapUrl); card.Children.Add(urlLabel); _ = Task.Run(async () => { var og = await FetchOgTagsAsync(url); if (og is null) return; MainThread.BeginInvokeOnMainThread(() => { if (!string.IsNullOrWhiteSpace(og.Title)) { card.Children.Add(new Label { Text = og.Title, FontSize = 13, FontAttributes = FontAttributes.Bold, MaxLines = 2, LineBreakMode = LineBreakMode.TailTruncation }); } if (!string.IsNullOrWhiteSpace(og.Description)) { card.Children.Add(new Label { Text = og.Description, FontSize = 11, TextColor = Colors.LightGray, MaxLines = 3, LineBreakMode = LineBreakMode.TailTruncation }); } if (!string.IsNullOrWhiteSpace(og.ImageUrl) && IsImageUrl(og.ImageUrl)) { card.Children.Add(new Image { Source = ImageSource.FromUri(new Uri(og.ImageUrl)), Aspect = Aspect.AspectFit, WidthRequest = 360, MaximumHeightRequest = 200, HorizontalOptions = LayoutOptions.Start }); } }); }); return new Border { StrokeThickness = 1, Padding = new Thickness(8, 6), Margin = new Thickness(0, 4, 0, 0), Content = card }; } private sealed record OgData(string? Title, string? Description, string? ImageUrl); /// /// 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). /// private static async Task FetchOgTagsAsync(string url) { try { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(4) }; client.DefaultRequestHeaders.Add("User-Agent", "Relay/1.0 (link preview)"); var html = await client.GetStringAsync(url); var title = GetMetaContent(html, "og:title") ?? GetTitleTag(html); var description = GetMetaContent(html, "og:description"); var image = GetMetaContent(html, "og:image"); if (title is null && description is null && image is null) return null; return new OgData(title, description, image); } catch { return null; } } private static string? GetMetaContent(string html, string property) { var pattern = $"""]+property=["']{Regex.Escape(property)}["'][^>]+content=["']([^"']+)["']"""; var m = Regex.Match(html, pattern, RegexOptions.IgnoreCase); if (m.Success) return System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()); var pattern2 = $"""]+content=["']([^"']+)["'][^>]+property=["']{Regex.Escape(property)}["']"""; m = Regex.Match(html, pattern2, RegexOptions.IgnoreCase); return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null; } private static string? GetTitleTag(string html) { var m = Regex.Match(html, @"]*>([^<]+)", RegexOptions.IgnoreCase); return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null; } /// True if the URL's path ends with a known image extension. Used to choose between BuildImageEmbed and BuildLinkCard. private static bool IsImageUrl(string url) { try { var path = new Uri(url).AbsolutePath; var ext = Path.GetExtension(path).ToLowerInvariant(); return ImageExtensions.Contains(ext); } catch { return false; } } private static readonly Regex YouTubePattern = new( @"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{6,})", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// Extracts the 11-char video ID from any YouTube URL form (watch, youtu.be, embed, shorts, /v/). private static bool TryGetYouTubeId(string url, out string id) { var match = YouTubePattern.Match(url); if (match.Success) { id = match.Groups[1].Value; return true; } id = string.Empty; return false; } private static readonly Regex VimeoPattern = new( @"vimeo\.com/(?:video/|channels/[^/]+/|groups/[^/]+/videos/)?(\d{6,})", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// Extracts the numeric video ID from Vimeo URLs. Handles vimeo.com/{id}, /video/{id}, channels/x/{id}, groups/x/videos/{id}. private static bool TryGetVimeoId(string url, out string id) { var match = VimeoPattern.Match(url); if (match.Success) { id = match.Groups[1].Value; return true; } id = string.Empty; return false; } /// YouTube embed card. Thumbnail comes from img.youtube.com; player swaps to the youtube.com/embed/ URL on tap. private static View BuildYouTubeCard(string url, string videoId) => BuildVideoCardWithEmbed( providerLabel: "🎬 YouTube", providerColor: Color.FromArgb("#FF4444"), externalUrl: url, thumbnailUrl: $"https://img.youtube.com/vi/{videoId}/hqdefault.jpg", embedUrl: $"https://www.youtube.com/embed/{videoId}?autoplay=1&rel=0"); /// Vimeo embed card. No thumbnail (Vimeo's API requires OAuth); placeholder stays black with a play badge until tap. private static View BuildVimeoCard(string url, string videoId) => BuildVideoCardWithEmbed( providerLabel: "🎬 Vimeo", providerColor: Color.FromArgb("#1AB7EA"), externalUrl: url, thumbnailUrl: null, // Vimeo thumbs require an API call; skip and show a black placeholder embedUrl: $"https://player.vimeo.com/video/{videoId}?autoplay=1"); /// /// 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. /// private static View BuildVideoCardWithEmbed( string providerLabel, Color providerColor, string externalUrl, string? thumbnailUrl, string embedUrl) { var card = new VerticalStackLayout { Spacing = 4 }; var headerRow = new HorizontalStackLayout { Spacing = 10 }; headerRow.Children.Add(new Label { Text = providerLabel, FontSize = 11, FontAttributes = FontAttributes.Bold, TextColor = providerColor }); var openExternal = new Label { Text = "↗ Open in browser", FontSize = 10, TextColor = Color.FromArgb("#8E8E93"), TextDecorations = TextDecorations.Underline }; var openTap = new TapGestureRecognizer(); openTap.Tapped += (_, _) => _ = Launcher.OpenAsync(new Uri(externalUrl)); openExternal.GestureRecognizers.Add(openTap); headerRow.Children.Add(openExternal); card.Children.Add(headerRow); var playerHost = new ContentView { HorizontalOptions = LayoutOptions.Start, Content = BuildThumbnailPlaceholder(thumbnailUrl, () => { // On tap → swap the placeholder for a real player. }) }; playerHost.Content = BuildThumbnailPlaceholder(thumbnailUrl, () => { playerHost.Content = BuildEmbeddedPlayer(embedUrl); }); card.Children.Add(playerHost); return new Border { StrokeThickness = 1, Padding = new Thickness(8, 6), Margin = new Thickness(0, 4, 0, 0), Content = card }; } /// /// 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. /// private static View BuildThumbnailPlaceholder(string? thumbnailUrl, Action onPlay) { var grid = new Grid { WidthRequest = 400, HeightRequest = 225, BackgroundColor = Colors.Black, HorizontalOptions = LayoutOptions.Start }; if (!string.IsNullOrWhiteSpace(thumbnailUrl)) { grid.Children.Add(new Image { Source = ImageSource.FromUri(new Uri(thumbnailUrl)), Aspect = Aspect.AspectFill }); } var playBadge = new Label { Text = "▶", FontSize = 36, TextColor = Colors.White, BackgroundColor = Color.FromArgb("#CC000000"), HorizontalTextAlignment = TextAlignment.Center, VerticalTextAlignment = TextAlignment.Center, WidthRequest = 64, HeightRequest = 64, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center }; grid.Children.Add(playBadge); var tap = new TapGestureRecognizer(); tap.Tapped += (_, _) => onPlay(); grid.GestureRecognizers.Add(tap); return grid; } /// The actual in-client video player. WebView2 (Windows) and WebKit (mobile) both handle YouTube/Vimeo embed pages. private static View BuildEmbeddedPlayer(string embedUrl) { return new WebView { Source = embedUrl, WidthRequest = 480, HeightRequest = 270, HorizontalOptions = LayoutOptions.Start }; } }