454 lines
15 KiB
C#
454 lines
15 KiB
C#
using System.Net.Http;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace RelayClient.Helpers;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> ImageExtensions =
|
|
[".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif"];
|
|
|
|
public static List<string> DetectUrls(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return [];
|
|
return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList();
|
|
}
|
|
|
|
public static List<View> BuildEmbeds(string text)
|
|
{
|
|
var views = new List<View>();
|
|
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;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
public static readonly BindableProperty JumpUrlProperty =
|
|
BindableProperty.CreateAttached("JumpUrl", typeof(string), typeof(EmbedHelper), null);
|
|
|
|
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);
|
|
|
|
private static async Task<OgData?> 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 = $"""<meta[^>]+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 = $"""<meta[^>]+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, @"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase);
|
|
return m.Success ? System.Net.WebUtility.HtmlDecode(m.Groups[1].Value.Trim()) : null;
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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;
|
|
}
|
|
|
|
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");
|
|
|
|
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");
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static View BuildEmbeddedPlayer(string embedUrl)
|
|
{
|
|
return new WebView
|
|
{
|
|
Source = embedUrl,
|
|
WidthRequest = 480,
|
|
HeightRequest = 270,
|
|
HorizontalOptions = LayoutOptions.Start
|
|
};
|
|
}
|
|
}
|