Files
Relay/RelayClient/Helpers/EmbedHelper.cs
RuKira f819d7284e Update: Text Channel Stuff
Bugs: Files don't work
Bugs: Video In-Line don't work

Added: idk, everything?
2026-06-03 13:19:21 -04:00

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