Update: Text Channel Stuff
Bugs: Files don't work Bugs: Video In-Line don't work Added: idk, everything?
This commit is contained in:
453
RelayClient/Helpers/EmbedHelper.cs
Normal file
453
RelayClient/Helpers/EmbedHelper.cs
Normal file
@@ -0,0 +1,453 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user