From f819d7284ec4c7188e6d06a06979177cc20b1edf Mon Sep 17 00:00:00 2001 From: RuKira Date: Wed, 3 Jun 2026 13:19:21 -0400 Subject: [PATCH] Update: Text Channel Stuff Bugs: Files don't work Bugs: Video In-Line don't work Added: idk, everything? --- RelayClient/Helpers/EmbedHelper.cs | 453 +++++++ RelayClient/Helpers/MarkdownHelper.cs | 377 ++++++ RelayClient/Helpers/SyntaxHighlighter.cs | 310 +++++ RelayClient/MainPage.xaml | 124 +- RelayClient/MainPage.xaml.cs | 1142 ++++++++++++++--- RelayClient/Services/RelaySocketClient.cs | 213 +-- .../Models/Chat/ChannelMessageEdits.cs | 12 + RelayServer/Models/Chat/ChannelMessages.cs | 4 +- RelayServer/Models/Chat/Channels.cs | 8 +- .../Models/Server/ChannelPermissions.cs | 11 + RelayServer/Models/Server/Roles.cs | 28 + RelayServer/Models/Server/UserRoles.cs | 10 + RelayServer/Program.cs | 1 + .../Services/Chat/ChatSocketBehavior.cs | 1132 +++++++++------- .../Services/Core/ServerBootstrapService.cs | 287 +++-- .../Services/Data/PermissionService.cs | 160 +++ RelayShared/Services/ChannelTransmissions.cs | 9 +- RelayShared/Services/ChatMessageContent.cs | 14 + RelayShared/Services/SocketTransmissions.cs | 46 +- RelayShared/Services/WsControlMessage.cs | 14 +- 20 files changed, 3447 insertions(+), 908 deletions(-) create mode 100644 RelayClient/Helpers/EmbedHelper.cs create mode 100644 RelayClient/Helpers/MarkdownHelper.cs create mode 100644 RelayClient/Helpers/SyntaxHighlighter.cs create mode 100644 RelayServer/Models/Chat/ChannelMessageEdits.cs create mode 100644 RelayServer/Models/Server/ChannelPermissions.cs create mode 100644 RelayServer/Models/Server/Roles.cs create mode 100644 RelayServer/Models/Server/UserRoles.cs create mode 100644 RelayServer/Services/Data/PermissionService.cs create mode 100644 RelayShared/Services/ChatMessageContent.cs diff --git a/RelayClient/Helpers/EmbedHelper.cs b/RelayClient/Helpers/EmbedHelper.cs new file mode 100644 index 0000000..38c4516 --- /dev/null +++ b/RelayClient/Helpers/EmbedHelper.cs @@ -0,0 +1,453 @@ +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"]; + + public static List DetectUrls(string text) + { + if (string.IsNullOrWhiteSpace(text)) return []; + return UrlPattern.Matches(text).Select(m => m.Value).Distinct().ToList(); + } + + 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; + } + + 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 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; + } + + 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 + }; + } +} diff --git a/RelayClient/Helpers/MarkdownHelper.cs b/RelayClient/Helpers/MarkdownHelper.cs new file mode 100644 index 0000000..5090903 --- /dev/null +++ b/RelayClient/Helpers/MarkdownHelper.cs @@ -0,0 +1,377 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace RelayClient.Helpers; + +public static class MarkdownHelper +{ + private static readonly Regex FencedCode = + new(@"```([A-Za-z0-9_+#-]*)\r?\n?(.*?)```", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Color MentionText = Color.FromArgb("#9EA8FF"); + private static readonly Color MentionBg = Color.FromArgb("#2D2F5C"); + private static readonly Color SpoilerBg = Color.FromArgb("#1F1F23"); + + public static View Render(string markdown, double fontSize = 14) + { + if (string.IsNullOrEmpty(markdown)) + return new Label { Text = string.Empty, FontSize = fontSize }; + + var stack = new VerticalStackLayout { Spacing = 2 }; + + var matches = FencedCode.Matches(markdown); + int cursor = 0; + + foreach (Match m in matches) + { + if (m.Index > cursor) + AppendTextSegment(stack, markdown[cursor..m.Index], fontSize); + + stack.Children.Add(CreateCodeBlock(m.Groups[1].Value.Trim(), m.Groups[2].Value.TrimEnd())); + cursor = m.Index + m.Length; + } + + if (cursor < markdown.Length) + AppendTextSegment(stack, markdown[cursor..], fontSize); + + return stack.Children.Count == 1 ? (View)stack.Children[0] : stack; + } + + private static void AppendTextSegment(VerticalStackLayout stack, string segment, double fontSize) + { + var paragraphBuffer = new StringBuilder(); + + void FlushParagraph() + { + if (paragraphBuffer.Length == 0) return; + stack.Children.Add(CreateInlineLabel(paragraphBuffer.ToString(), fontSize)); + paragraphBuffer.Clear(); + } + + foreach (var rawLine in segment.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + + if (string.IsNullOrWhiteSpace(line)) + { + FlushParagraph(); + continue; + } + + if (line.StartsWith("### ")) + { + FlushParagraph(); + stack.Children.Add(CreateHeaderLabel(line[4..], fontSize + 3)); + continue; + } + + if (line.StartsWith("## ")) + { + FlushParagraph(); + stack.Children.Add(CreateHeaderLabel(line[3..], fontSize + 6)); + continue; + } + + if (line.StartsWith("# ")) + { + FlushParagraph(); + stack.Children.Add(CreateHeaderLabel(line[2..], fontSize + 10)); + continue; + } + + if (line.StartsWith("-# ")) + { + FlushParagraph(); + stack.Children.Add(CreateSubtextLabel(line[3..], fontSize - 3)); + continue; + } + + if (paragraphBuffer.Length > 0) + paragraphBuffer.Append('\n'); + paragraphBuffer.Append(line); + } + + FlushParagraph(); + } + + private static View CreateCodeBlock(string language, string code) + { + var label = new Label + { + FontFamily = "AnonymousProRegular", + FontSize = 12, + TextColor = Color.FromArgb("#D4D4D4"), + LineBreakMode = LineBreakMode.WordWrap + }; + + var spans = SyntaxHighlighter.Highlight(code, language, 12); + if (spans.Count > 0) + { + var fs = new FormattedString(); + foreach (var s in spans) fs.Spans.Add(s); + label.FormattedText = fs; + } + else + { + label.Text = code; + } + + var stack = new VerticalStackLayout { Spacing = 4 }; + + if (!string.IsNullOrWhiteSpace(language)) + { + stack.Children.Add(new Label + { + Text = language.ToLowerInvariant(), + FontFamily = "AnonymousProRegular", + FontSize = 10, + TextColor = Color.FromArgb("#6A9955"), + FontAttributes = FontAttributes.Bold + }); + } + + stack.Children.Add(label); + + return new Border + { + BackgroundColor = Color.FromArgb("#1E1E1E"), + StrokeThickness = 0, + Padding = new Thickness(10, 6), + Content = stack + }; + } + + private static Label CreateHeaderLabel(string text, double size) + { + var label = new Label + { + FontSize = size, + FontAttributes = FontAttributes.Bold, + LineBreakMode = LineBreakMode.WordWrap, + Margin = new Thickness(0, 4, 0, 2) + }; + + var fs = new FormattedString(); + var spoilerSpans = new List(); + ParseInline(text, fs.Spans, size, spoilerSpans); + + if (fs.Spans.Count > 0) label.FormattedText = fs; + else label.Text = text; + + WireSpoilerTap(label, spoilerSpans); + return label; + } + + private static Label CreateSubtextLabel(string text, double size) + { + var label = new Label + { + FontSize = size, + TextColor = Color.FromArgb("#8E8E93"), + LineBreakMode = LineBreakMode.WordWrap + }; + + var fs = new FormattedString(); + var spoilerSpans = new List(); + ParseInline(text, fs.Spans, size, spoilerSpans); + + if (fs.Spans.Count > 0) + { + foreach (var s in fs.Spans) + s.TextColor ??= Color.FromArgb("#8E8E93"); + label.FormattedText = fs; + } + else + { + label.Text = text; + } + + WireSpoilerTap(label, spoilerSpans); + return label; + } + + private static Label CreateInlineLabel(string text, double fontSize) + { + var label = new Label { FontSize = fontSize, LineBreakMode = LineBreakMode.WordWrap }; + var fs = new FormattedString(); + var spoilerSpans = new List(); + ParseInline(text, fs.Spans, fontSize, spoilerSpans); + + if (fs.Spans.Count > 0) label.FormattedText = fs; + else label.Text = text; + + WireSpoilerTap(label, spoilerSpans); + return label; + } + + private static void WireSpoilerTap(Label label, List spoilerSpans) + { + if (spoilerSpans.Count == 0) return; + + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => + { + foreach (var s in spoilerSpans) + { + s.BackgroundColor = Colors.Transparent; + s.TextColor = null; // fall back to default label color + } + }; + label.GestureRecognizers.Add(tap); + } + + private static void ParseInline(string text, IList spans, double fontSize, List spoilerSpans) + { + var plain = new StringBuilder(); + int i = 0; + + void Flush() + { + if (plain.Length == 0) return; + spans.Add(new Span { Text = plain.ToString(), FontSize = fontSize }); + plain.Clear(); + } + + while (i < text.Length) + { + char c = text[i]; + + if (c == '|' && Peek(text, i + 1) == '|') + { + int end = text.IndexOf("||", i + 2, StringComparison.Ordinal); + if (end > i + 2) + { + Flush(); + var span = new Span + { + Text = text[(i + 2)..end], + FontSize = fontSize, + BackgroundColor = SpoilerBg, + TextColor = SpoilerBg // text invisible until revealed + }; + spans.Add(span); + spoilerSpans.Add(span); + i = end + 2; + continue; + } + } + + if (c == '@' && i + 1 < text.Length && + (char.IsLetter(text[i + 1]) || text[i + 1] == '_')) + { + int end = i + 1; + while (end < text.Length && (char.IsLetterOrDigit(text[end]) || text[end] == '_')) + end++; + + Flush(); + spans.Add(new Span + { + Text = text[i..end], + TextColor = MentionText, + BackgroundColor = MentionBg, + FontAttributes = FontAttributes.Bold, + FontSize = fontSize + }); + i = end; + continue; + } + + if (c == '~' && Peek(text, i + 1) == '~') + { + int end = text.IndexOf("~~", i + 2, StringComparison.Ordinal); + if (end > i + 2) + { + Flush(); + spans.Add(new Span + { + Text = text[(i + 2)..end], + FontSize = fontSize, + TextDecorations = TextDecorations.Strikethrough + }); + i = end + 2; continue; + } + } + + if (c == '_' && Peek(text, i + 1) == '_') + { + int end = text.IndexOf("__", i + 2, StringComparison.Ordinal); + if (end > i + 2) + { + Flush(); + spans.Add(new Span + { + Text = text[(i + 2)..end], + FontSize = fontSize, + TextDecorations = TextDecorations.Underline + }); + i = end + 2; continue; + } + } + + if (c == '*' && Peek(text, i + 1) == '*') + { + int end = text.IndexOf("**", i + 2, StringComparison.Ordinal); + if (end > i + 2) + { + Flush(); + spans.Add(new Span + { + Text = text[(i + 2)..end], + FontSize = fontSize, + FontAttributes = FontAttributes.Bold + }); + i = end + 2; continue; + } + } + + if (c == '*' && Peek(text, i + 1) != '*') + { + int end = FindClosingSingle(text, '*', i + 1); + if (end > i + 1) + { + Flush(); + spans.Add(new Span + { + Text = text[(i + 1)..end], + FontSize = fontSize, + FontAttributes = FontAttributes.Italic + }); + i = end + 1; continue; + } + } + + if (c == '`') + { + int end = text.IndexOf('`', i + 1); + if (end > i + 1) + { + Flush(); + spans.Add(new Span + { + Text = text[(i + 1)..end], + FontFamily = "AnonymousProRegular", + FontSize = fontSize - 1, + BackgroundColor = Color.FromArgb("#2D2D2D"), + TextColor = Color.FromArgb("#CE9178") + }); + i = end + 1; continue; + } + } + + plain.Append(c); + i++; + } + + Flush(); + } + + private static char Peek(string text, int index) => index < text.Length ? text[index] : '\0'; + + private static int FindClosingSingle(string text, char marker, int start) + { + for (int i = start; i < text.Length; i++) + if (text[i] == marker && Peek(text, i + 1) != marker) + return i; + return -1; + } +} diff --git a/RelayClient/Helpers/SyntaxHighlighter.cs b/RelayClient/Helpers/SyntaxHighlighter.cs new file mode 100644 index 0000000..6f197ad --- /dev/null +++ b/RelayClient/Helpers/SyntaxHighlighter.cs @@ -0,0 +1,310 @@ +using System.Text.RegularExpressions; + +namespace RelayClient.Helpers; + +public static class SyntaxHighlighter +{ + private static readonly Color DefaultColor = Color.FromArgb("#D4D4D4"); + private static readonly Color KeywordColor = Color.FromArgb("#569CD6"); + private static readonly Color StringColor = Color.FromArgb("#CE9178"); + private static readonly Color NumberColor = Color.FromArgb("#B5CEA8"); + private static readonly Color CommentColor = Color.FromArgb("#6A9955"); + private static readonly Color TypeColor = Color.FromArgb("#4EC9B0"); + private static readonly Color FunctionColor = Color.FromArgb("#DCDCAA"); + private static readonly Color OperatorColor = Color.FromArgb("#D4D4D4"); + private static readonly Color TagColor = Color.FromArgb("#569CD6"); + private static readonly Color AttrColor = Color.FromArgb("#9CDCFE"); + + private const string FontFamily = "AnonymousProRegular"; + + private static readonly Dictionary Aliases = new(StringComparer.OrdinalIgnoreCase) + { + ["cs"] = "csharp", + ["c#"] = "csharp", + ["js"] = "javascript", + ["jsx"] = "javascript", + ["ts"] = "typescript", + ["tsx"] = "typescript", + ["py"] = "python", + ["sh"] = "bash", + ["shell"] = "bash", + ["zsh"] = "bash", + ["htm"] = "html", + ["xml"] = "html", + ["yml"] = "yaml" + }; + + private static readonly Dictionary> Keywords = new(StringComparer.OrdinalIgnoreCase) + { + ["csharp"] = new(StringComparer.Ordinal) + { + "abstract","as","async","await","base","bool","break","byte","case","catch","char","checked", + "class","const","continue","decimal","default","delegate","do","double","else","enum","event", + "explicit","extern","false","finally","fixed","float","for","foreach","get","goto","if", + "implicit","in","int","interface","internal","is","lock","long","namespace","new","null", + "object","operator","out","override","params","partial","private","protected","public", + "readonly","record","ref","return","sbyte","sealed","set","short","sizeof","stackalloc", + "static","string","struct","switch","this","throw","true","try","typeof","uint","ulong", + "unchecked","unsafe","ushort","using","var","virtual","void","volatile","while","yield", + "nameof","when","where","global","init","required","file","scoped","with" + }, + ["javascript"] = new(StringComparer.Ordinal) + { + "async","await","break","case","catch","class","const","continue","debugger","default", + "delete","do","else","enum","export","extends","false","finally","for","from","function", + "get","if","implements","import","in","instanceof","let","new","null","of","package", + "private","protected","public","return","set","static","super","switch","this","throw", + "true","try","typeof","undefined","var","void","while","with","yield" + }, + ["typescript"] = new(StringComparer.Ordinal) + { + "any","as","async","await","boolean","break","case","catch","class","const","continue", + "debugger","declare","default","delete","do","else","enum","export","extends","false", + "finally","for","from","function","get","if","implements","import","in","instanceof", + "interface","is","keyof","let","namespace","never","new","null","number","of","package", + "private","protected","public","readonly","return","set","static","string","super", + "switch","this","throw","true","try","type","typeof","undefined","unknown","var","void", + "while","with","yield" + }, + ["python"] = new(StringComparer.Ordinal) + { + "and","as","assert","async","await","break","class","continue","def","del","elif","else", + "except","False","finally","for","from","global","if","import","in","is","lambda","None", + "nonlocal","not","or","pass","raise","return","True","try","while","with","yield","self", + "cls","match","case" + }, + ["sql"] = new(StringComparer.OrdinalIgnoreCase) + { + "select","from","where","insert","update","delete","create","alter","drop","table","index", + "view","join","inner","outer","left","right","full","cross","on","as","group","by","order", + "having","distinct","union","all","into","values","set","null","not","and","or","in","like", + "between","is","true","false","primary","key","foreign","references","default","limit", + "offset","with","case","when","then","else","end","exists","cast","begin","commit","rollback" + }, + ["bash"] = new(StringComparer.Ordinal) + { + "if","then","else","elif","fi","for","in","do","done","while","until","case","esac", + "function","return","break","continue","exit","echo","printf","export","local","readonly", + "source","alias","unset","trap","set","eval","exec","shift","let","declare","typeset" + }, + ["json"] = new(StringComparer.Ordinal) { "true","false","null" }, + ["yaml"] = new(StringComparer.Ordinal) { "true","false","null","yes","no","on","off" }, + ["css"] = new(StringComparer.OrdinalIgnoreCase) + { + "important","inherit","initial","unset","auto","none","normal","bold","italic","center", + "left","right","top","bottom","flex","grid","block","inline","absolute","relative","fixed", + "sticky","static" + } + }; + + private static readonly Dictionary Tokenizers = new(StringComparer.Ordinal); + + static SyntaxHighlighter() + { + const RegexOptions opts = RegexOptions.Compiled | RegexOptions.Singleline; + + Tokenizers["csharp"] = new Regex( + @"(?//[^\n]*|/\*.*?\*/)" + + @"|(?@""(?:""""|[^""])*""|\$""(?:\\.|[^""\\])*""|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" + + @"|(?\b\d+(?:\.\d+)?[fFdDmMuUlL]*\b)" + + @"|(?[A-Za-z_]\w*)", + opts); + + Tokenizers["javascript"] = new Regex( + @"(?//[^\n]*|/\*.*?\*/)" + + @"|(?""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)" + + @"|(?\b\d+(?:\.\d+)?\b)" + + @"|(?[A-Za-z_$][\w$]*)", + opts); + + Tokenizers["typescript"] = Tokenizers["javascript"]; + + Tokenizers["python"] = new Regex( + @"(?\#[^\n]*)" + + @"|(?""""""[\s\S]*?""""""|'''[\s\S]*?'''|""(?:\\.|[^""\\])*""|'(?:\\.|[^'\\])*')" + + @"|(?\b\d+(?:\.\d+)?\b)" + + @"|(?[A-Za-z_]\w*)", + opts); + + Tokenizers["sql"] = new Regex( + @"(?--[^\n]*|/\*.*?\*/)" + + @"|(?'(?:''|[^'])*')" + + @"|(?\b\d+(?:\.\d+)?\b)" + + @"|(?[A-Za-z_]\w*)", + opts); + + Tokenizers["bash"] = new Regex( + @"(?\#[^\n]*)" + + @"|(?""(?:\\.|[^""\\])*""|'[^']*')" + + @"|(?\b\d+\b)" + + @"|(?\$\{?[A-Za-z_]\w*\}?)" + + @"|(?[A-Za-z_][\w-]*)", + opts); + + Tokenizers["json"] = new Regex( + @"(?""(?:\\.|[^""\\])*"")" + + @"|(?-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)" + + @"|(?true|false|null)", + opts); + + Tokenizers["yaml"] = new Regex( + @"(?\#[^\n]*)" + + @"|(?""(?:\\.|[^""\\])*""|'[^']*')" + + @"|(?^[ \t]*[A-Za-z_][\w-]*(?=\s*:))" + + @"|(?\b\d+(?:\.\d+)?\b)" + + @"|(?[A-Za-z_][\w-]*)", + opts | RegexOptions.Multiline); + + Tokenizers["html"] = new Regex( + @"(?)" + + @"|(?""[^""]*""|'[^']*')" + + @"|(?\b[A-Za-z_][\w-]*(?==))", + opts); + + Tokenizers["css"] = new Regex( + @"(?/\*.*?\*/)" + + @"|(?""[^""]*""|'[^']*')" + + @"|(?-?\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b)" + + @"|(?[.#]?[A-Za-z_][\w-]*(?=\s*[{,]))" + + @"|(?[A-Za-z-]+(?=\s*:))" + + @"|(?[A-Za-z_][\w-]*)", + opts); + + Tokenizers["diff"] = new Regex( + @"(?^\+[^\n]*)" + + @"|(?^-[^\n]*)" + + @"|(?^@@[^\n]*)", + opts | RegexOptions.Multiline); + + Tokenizers["markdown"] = new Regex( + @"(?
^#{1,6}[^\n]*)" + + @"|(?\*\*[^*\n]+\*\*|__[^_\n]+__)" + + @"|(?\*[^*\n]+\*|_[^_\n]+_)" + + @"|(?`[^`\n]+`)" + + @"|(?\[[^\]]+\]\([^)]+\))", + opts | RegexOptions.Multiline); + } + + public static List Highlight(string code, string? language, double fontSize) + { + var lang = Resolve(language); + var spans = new List(); + + if (lang is null || !Tokenizers.TryGetValue(lang, out var tokenizer)) + { + spans.Add(MakeSpan(code, DefaultColor, fontSize)); + return spans; + } + + var keywords = Keywords.GetValueOrDefault(lang); + int cursor = 0; + + foreach (Match m in tokenizer.Matches(code)) + { + if (m.Index > cursor) + spans.Add(MakeSpan(code[cursor..m.Index], DefaultColor, fontSize)); + + spans.Add(SpanForMatch(m, lang, keywords, fontSize)); + cursor = m.Index + m.Length; + } + + if (cursor < code.Length) + spans.Add(MakeSpan(code[cursor..], DefaultColor, fontSize)); + + return spans; + } + + private static Span SpanForMatch(Match m, string lang, HashSet? keywords, double fontSize) + { + if (m.Groups["comment"].Success) + return MakeSpan(m.Value, CommentColor, fontSize, italic: true); + + if (m.Groups["string"].Success) + return MakeSpan(m.Value, StringColor, fontSize); + + if (m.Groups["number"].Success) + return MakeSpan(m.Value, NumberColor, fontSize); + + if (m.Groups["variable"].Success) + return MakeSpan(m.Value, AttrColor, fontSize); + + if (m.Groups["tag"].Success) + return MakeSpan(m.Value, TagColor, fontSize); + + if (m.Groups["attr"].Success) + return MakeSpan(m.Value, AttrColor, fontSize); + + if (m.Groups["selector"].Success) + return MakeSpan(m.Value, TypeColor, fontSize); + + if (m.Groups["prop"].Success) + return MakeSpan(m.Value, AttrColor, fontSize); + + if (m.Groups["key"].Success) + return MakeSpan(m.Value, AttrColor, fontSize); + + if (m.Groups["add"].Success) + return MakeSpan(m.Value, Color.FromArgb("#6A9955"), fontSize); + + if (m.Groups["del"].Success) + return MakeSpan(m.Value, Color.FromArgb("#F48771"), fontSize); + + if (m.Groups["hunk"].Success) + return MakeSpan(m.Value, KeywordColor, fontSize); + + if (m.Groups["header"].Success) + return MakeSpan(m.Value, KeywordColor, fontSize, bold: true); + + if (m.Groups["bold"].Success) + return MakeSpan(m.Value, DefaultColor, fontSize, bold: true); + + if (m.Groups["italic"].Success) + return MakeSpan(m.Value, DefaultColor, fontSize, italic: true); + + if (m.Groups["code"].Success) + return MakeSpan(m.Value, StringColor, fontSize); + + if (m.Groups["link"].Success) + return MakeSpan(m.Value, AttrColor, fontSize); + + if (m.Groups["word"].Success) + { + var word = m.Value; + var compareSet = keywords; + + if (compareSet is not null && compareSet.Contains(word)) + return MakeSpan(word, KeywordColor, fontSize); + + if (lang is "csharp" or "javascript" or "typescript" && word.Length > 0 && char.IsUpper(word[0])) + return MakeSpan(word, TypeColor, fontSize); + + return MakeSpan(word, DefaultColor, fontSize); + } + + return MakeSpan(m.Value, DefaultColor, fontSize); + } + + private static Span MakeSpan(string text, Color color, double fontSize, bool bold = false, bool italic = false) + { + var attrs = FontAttributes.None; + if (bold) attrs |= FontAttributes.Bold; + if (italic) attrs |= FontAttributes.Italic; + + return new Span + { + Text = text, + TextColor = color, + FontSize = fontSize, + FontFamily = FontFamily, + FontAttributes = attrs + }; + } + + private static string? Resolve(string? language) + { + if (string.IsNullOrWhiteSpace(language)) return null; + var lower = language.Trim().ToLowerInvariant(); + return Aliases.GetValueOrDefault(lower, lower); + } +} diff --git a/RelayClient/MainPage.xaml b/RelayClient/MainPage.xaml index 6951c9b..9a25d80 100644 --- a/RelayClient/MainPage.xaml +++ b/RelayClient/MainPage.xaml @@ -1,4 +1,4 @@ - + - - -