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