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(
+ @"(?)" +
+ @"|(?""[^""]*""|'[^']*')" +
+ @"|(??[A-Za-z][A-Za-z0-9-]*)" +
+ @"|(?\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 @@
-
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
+
+
+
+
+
-
-
+
+
-
+
-
-
-
+
+
+
+
-
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs
index 8b22af0..a80f2ff 100644
--- a/RelayClient/MainPage.xaml.cs
+++ b/RelayClient/MainPage.xaml.cs
@@ -1,5 +1,8 @@
-using System.Text.Json.Serialization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
using RelayClient.Crypto;
+using RelayClient.Helpers;
using RelayClient.Services;
using RelayShared.Rtc;
using RelayShared.Services;
@@ -8,25 +11,44 @@ namespace RelayClient;
public partial class MainPage : ContentPage
{
- public static string _username;
+ public static string _username = string.Empty;
private readonly RelaySocketClient _socket;
private readonly RtcBridgeService _rtc;
public static string? _userToken;
-
+
private string? _currentChannelId;
private string? _currentChannelName;
+ private ChannelType _currentChannelType = ChannelType.Text;
private readonly Dictionary> _messagesByChannel = new();
private readonly List _channels = [];
+ private readonly Dictionary _messagesById = new();
+ private readonly Dictionary _messageBubbles = new();
+
+ private ChatMessage? _replyingToMessage;
+ private string? _editingMessageId;
+
+ private string? _pendingAttachmentBase64;
+ private string? _pendingAttachmentMimeType;
+ private string? _pendingAttachmentFileName;
+
+ private CancellationTokenSource? _typingDebounce;
+ private readonly Dictionary _typingClearTimers = new();
+
+ private bool _channelsInitialized;
+
+ private readonly Dictionary _mentionCounts = new();
+
+ private static readonly Regex MentionExtract =
+ new(@"@(\w+)", RegexOptions.Compiled);
public MainPage(string username)
{
InitializeComponent();
- _username = username;
-
- UserLabel.Text = $"Logged in as: {_username}";
+ _username = username;
+ UserLabel.Text = $"Logged in as: {_username}";
if (!KeyStorage.HasKeys(_username))
{
@@ -35,83 +57,493 @@ public partial class MainPage : ContentPage
KeyStorage.SavePublicKey(_username, keys.publicKey);
}
- var waitFor = ServerAPI.setupClient();
+ _ = ServerAPI.setupClient();
_socket = new RelaySocketClient(_username);
- _rtc = new RtcBridgeService(
- _username,
- _socket,
- hybridWebView,
+ _rtc = new RtcBridgeService(
+ _username, _socket, hybridWebView,
() => _currentChannelId,
- SafeSendRawToWebView
- );
-
+ SafeSendRawToWebView);
+
hybridWebView.SetInvokeJavaScriptTarget(_rtc);
- _socket.Log += Console.WriteLine;
- _socket.ChannelListReceived += HandleChannelList;
- _socket.EncryptedChatReceived += HandleEncryptedChat;
+ _socket.Log += Console.WriteLine;
+ _socket.ChannelListReceived += HandleChannelList;
+ _socket.EncryptedChatReceived += HandleEncryptedChat;
+ _socket.MessageEdited += HandleMessageEdited;
+ _socket.MessageDeleted += HandleMessageDeleted;
+ _socket.TypingReceived += HandleTyping;
+ _socket.EditHistoryReceived += HandleEditHistory;
_socket.EncryptedRtcSignalReceived += payload =>
- {
- MainThread.BeginInvokeOnMainThread(async () =>
- {
- await _rtc.HandleIncomingRtcSignalAsync(payload);
- });
- };
+ MainThread.BeginInvokeOnMainThread(async () => await _rtc.HandleIncomingRtcSignalAsync(payload));
- // while(!waitFor.IsCompleted){}
-
_socket.Connect();
+ SetupEditorKeyHandler();
+ SetupFileDragAndDrop();
}
- private void SendButton_OnClicked(object? sender, EventArgs e)
+ private void SetupFileDragAndDrop()
{
- SendMessage();
+#if WINDOWS
+ MessagesView.HandlerChanged += (s, e) =>
+ {
+ if (MessagesView.Handler?.PlatformView is not Microsoft.UI.Xaml.UIElement el) return;
+
+ el.AllowDrop = true;
+
+ el.DragOver += (_, args) =>
+ {
+ args.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Copy;
+ args.DragUIOverride.Caption = "Attach to message";
+ args.DragUIOverride.IsCaptionVisible = true;
+ args.DragUIOverride.IsGlyphVisible = true;
+ args.Handled = true;
+ };
+
+ el.Drop += async (_, args) =>
+ {
+ args.Handled = true;
+ try
+ {
+ if (!args.DataView.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.StorageItems))
+ return;
+
+ var items = await args.DataView.GetStorageItemsAsync();
+ var file = items.OfType().FirstOrDefault();
+ if (file is null) return;
+
+ await IngestPickedFileAsync(file.Path, file.Name);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Drop failed: {ex.Message}");
+ }
+ };
+ };
+#endif
}
- private void MessageEntry_OnCompleted(object? sender, EventArgs e)
+ private void SetupEditorKeyHandler()
{
- SendMessage();
+#if WINDOWS
+ MessageEntry.HandlerChanged += (s, e) =>
+ {
+ if (MessageEntry.Handler?.PlatformView is not Microsoft.UI.Xaml.Controls.TextBox tb)
+ return;
+
+ // CRITICAL: with AcceptsReturn = true, the TextBox inserts the newline *before*
+ // KeyDown fires for us, so args.Handled = true is too late. Turn it off and
+ // handle Enter ourselves — bare Enter sends, Shift+Enter inserts \n manually.
+ tb.AcceptsReturn = false;
+
+ // PreviewKeyDown is exposed in WinUI via AddHandler with handledEventsToo: true.
+ tb.AddHandler(
+ Microsoft.UI.Xaml.UIElement.KeyDownEvent,
+ new Microsoft.UI.Xaml.Input.KeyEventHandler((sender, args) =>
+ {
+ if (args.Key != Windows.System.VirtualKey.Enter) return;
+
+ var shift = Microsoft.UI.Input.InputKeyboardSource
+ .GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift);
+ bool shiftHeld = shift.HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down);
+
+ args.Handled = true;
+
+ if (shiftHeld)
+ {
+ // Shift+Enter → insert a newline at the caret.
+ int caret = tb.SelectionStart;
+ var text = tb.Text ?? string.Empty;
+ tb.Text = text.Insert(caret, Environment.NewLine);
+ tb.SelectionStart = caret + Environment.NewLine.Length;
+ }
+ else
+ {
+ // Bare Enter → send the message.
+ MainThread.BeginInvokeOnMainThread(SendMessage);
+ }
+ }),
+ handledEventsToo: true);
+ };
+#endif
+ }
+
+ private void SendButton_OnClicked(object? sender, EventArgs e) => SendMessage();
+
+ private void MessageEntry_OnTextChanged(object? sender, TextChangedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_currentChannelId)) return;
+
+ _typingDebounce?.Cancel();
+ _typingDebounce = new CancellationTokenSource();
+ var token = _typingDebounce.Token;
+
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(400, token);
+ if (!token.IsCancellationRequested)
+ _socket.SendTyping(_currentChannelId!);
+ }, token);
}
private void SendMessage()
{
+ if (!MessageEntry.IsEnabled) return;
+
var text = MessageEntry.Text?.Trim();
+ if (string.IsNullOrWhiteSpace(text) && _pendingAttachmentBase64 is null) return;
- if (string.IsNullOrWhiteSpace(text))
- return;
+ if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey) ||
+ string.IsNullOrWhiteSpace(_currentChannelId)) return;
- if (string.IsNullOrWhiteSpace(_socket.ServerPublicKey))
+ if (_editingMessageId is not null)
{
- Console.WriteLine("Server public key not loaded yet.");
+ var editContent = new ChatMessageContent { Text = text ?? string.Empty };
+ var editEncrypted = E2EeHelper.EncryptForRecipient(
+ JsonSerializer.Serialize(editContent), _socket.ServerPublicKey);
+ _socket.SendEditMessage(_editingMessageId, _currentChannelId!, editEncrypted);
+ CancelContext();
return;
}
- if (string.IsNullOrWhiteSpace(_currentChannelId))
+ var mentions = MentionExtract
+ .Matches(text ?? string.Empty)
+ .Select(m => m.Groups[1].Value)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (_replyingToMessage is not null &&
+ !mentions.Contains(_replyingToMessage.SenderUsername, StringComparer.OrdinalIgnoreCase))
{
- Console.WriteLine("No channel selected yet.");
- return;
+ mentions.Add(_replyingToMessage.SenderUsername);
}
- var encrypted = E2EeHelper.EncryptForRecipient(text, _socket.ServerPublicKey);
-
- var payload = new SocketEncryptedMessage
+ var content = new ChatMessageContent
{
- ChannelId = _currentChannelId!,
- Type = SignalType.ClientEncryptedChat,
- SenderUsername = _username,
- CipherText = encrypted.CipherText,
- Nonce = encrypted.Nonce,
- Tag = encrypted.Tag,
- EncryptedKey = encrypted.EncryptedKey
+ Text = text ?? string.Empty,
+ ReplyToId = _replyingToMessage?.MessageId,
+ ReplyToSenderUsername = _replyingToMessage?.SenderUsername,
+ ReplyPreview = _replyingToMessage is null ? null
+ : (_replyingToMessage.Text.Length > 100
+ ? _replyingToMessage.Text[..100] + "…"
+ : _replyingToMessage.Text),
+ Mentions = mentions.Count > 0 ? mentions : null,
+ AttachmentBase64 = _pendingAttachmentBase64,
+ AttachmentMimeType = _pendingAttachmentMimeType,
+ AttachmentFileName = _pendingAttachmentFileName
};
- _socket.SendJson(payload);
-
- Console.WriteLine($"[{_username}] sent encrypted message.");
+ var channelId = _currentChannelId!;
+ var serverPublicKey = _socket.ServerPublicKey!;
+ var attachmentName = _pendingAttachmentFileName;
+ CancelContext();
+ ClearPendingAttachment();
MessageEntry.Text = string.Empty;
- MessageEntry.Focus();
+
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ var contentJson = JsonSerializer.Serialize(content);
+ var encrypted = E2EeHelper.EncryptForRecipient(contentJson, serverPublicKey);
+
+ _socket.SendJson(new SocketEncryptedMessage
+ {
+ ChannelId = channelId,
+ Type = SignalType.ClientEncryptedChat,
+ SenderUsername = _username,
+ CipherText = encrypted.CipherText,
+ Nonce = encrypted.Nonce,
+ Tag = encrypted.Tag,
+ EncryptedKey = encrypted.EncryptedKey
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Send failed: {ex}");
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ await DisplayAlert("Send failed",
+ attachmentName is null
+ ? $"Could not send message: {ex.Message}"
+ : $"Could not send {attachmentName}: {ex.Message}",
+ "OK");
+ });
+ }
+ });
+ }
+
+ private async void AttachFile_OnClicked(object? sender, EventArgs e)
+ {
+ try
+ {
+ var result = await FilePicker.Default.PickAsync(new PickOptions
+ {
+ PickerTitle = "Attach a file"
+ });
+
+ if (result is null) return;
+ await IngestPickedFileAsync(result.FullPath, result.FileName);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"File attach failed: {ex.Message}");
+ await DisplayAlert("Attach failed", ex.Message, "OK");
+ }
+ }
+
+ private async Task IngestPickedFileAsync(string fullPath, string fileName)
+ {
+ if (!File.Exists(fullPath))
+ {
+ await DisplayAlert("Attach failed", $"File not found:\n{fullPath}", "OK");
+ return;
+ }
+
+ var bytes = await File.ReadAllBytesAsync(fullPath);
+
+ const long maxBytes = 50L * 1024 * 1024;
+
+ if (bytes.Length > maxBytes)
+ {
+ await DisplayAlert("File too large",
+ $"'{fileName}' is {bytes.Length / (1024.0 * 1024.0):F1} MB. " +
+ "Attachments must be under 50 MB.",
+ "OK");
+ return;
+ }
+
+ _pendingAttachmentBase64 = Convert.ToBase64String(bytes);
+ _pendingAttachmentFileName = fileName;
+ _pendingAttachmentMimeType = GetMimeType(fileName);
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ContextBarLabel.Text = $"📎 {fileName} attached — Send or press ✕ to remove";
+ ContextBar.IsVisible = true;
+ MessageEntry.Focus();
+ });
+ }
+
+ private void ClearPendingAttachment()
+ {
+ _pendingAttachmentBase64 = null;
+ _pendingAttachmentMimeType = null;
+ _pendingAttachmentFileName = null;
+ }
+
+ private static string GetMimeType(string fileName)
+ {
+ var ext = Path.GetExtension(fileName).ToLowerInvariant();
+ return ext switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ ".gif" => "image/gif",
+ ".webp" => "image/webp",
+ ".bmp" => "image/bmp",
+ ".pdf" => "application/pdf",
+ ".zip" => "application/zip",
+ ".txt" => "text/plain",
+ _ => "application/octet-stream"
+ };
+ }
+
+ private void CancelContext_OnClicked(object? sender, EventArgs e) => CancelContext();
+
+ private void CancelContext()
+ {
+ _replyingToMessage = null;
+ _editingMessageId = null;
+ ClearPendingAttachment();
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ContextBar.IsVisible = false;
+ ContextBarLabel.Text = string.Empty;
+ MessageEntry.Text = string.Empty;
+ });
+ }
+
+ private void StartReply(ChatMessage message)
+ {
+ _replyingToMessage = message;
+ _editingMessageId = null;
+ var preview = message.Text.Length > 80 ? message.Text[..80] + "…" : message.Text;
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ContextBarLabel.Text = $"↩ {message.SenderUsername}: {preview}";
+ ContextBar.IsVisible = true;
+ MessageEntry.Focus();
+ });
+ }
+
+ private void StartEdit(ChatMessage message)
+ {
+ _editingMessageId = message.MessageId;
+ _replyingToMessage = null;
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ ContextBarLabel.Text = "✏ Editing message — Enter to save, ✕ to cancel";
+ ContextBar.IsVisible = true;
+ MessageEntry.Text = message.Text;
+ MessageEntry.Focus();
+ });
+ }
+
+ private void ConfirmDelete(ChatMessage message) =>
+ _socket.SendDeleteMessage(message.MessageId, _currentChannelId ?? string.Empty);
+
+ private void CopyMessageLink(ChatMessage message)
+ {
+ if (string.IsNullOrWhiteSpace(message.MessageId)) return;
+ var link = $"relay://jump/{_currentChannelId}/{message.MessageId}";
+ _ = Clipboard.Default.SetTextAsync(link);
+ SafeSendRawToWebView($"Link copied: {link}");
+ }
+
+ private void AttachMessageContextMenu(Border bubble, ChatMessage message)
+ {
+#if WINDOWS
+ bubble.HandlerChanged += (s, e) =>
+ {
+ if (bubble.Handler?.PlatformView is not Microsoft.UI.Xaml.FrameworkElement fe) return;
+ fe.RightTapped += async (_, args) =>
+ {
+ args.Handled = true;
+ await ShowMessageContextMenuAsync(message);
+ };
+ };
+#else
+ var longPress = new TapGestureRecognizer { NumberOfTapsRequired = 2 };
+ longPress.Tapped += async (_, _) => await ShowMessageContextMenuAsync(message);
+ bubble.GestureRecognizers.Add(longPress);
+#endif
+ }
+
+ private async Task ShowMessageContextMenuAsync(ChatMessage message)
+ {
+ if (message.IsDeleted) return;
+
+ bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase);
+
+ var options = new List { "↩ Reply", "🔗 Copy Message Link" };
+ if (isOwn)
+ {
+ options.Add("✏ Edit");
+ options.Add("🗑 Delete");
+ }
+
+ var action = await DisplayActionSheet(null, "Cancel", null, [.. options]);
+ switch (action)
+ {
+ case "↩ Reply": StartReply(message); break;
+ case "🔗 Copy Message Link": CopyMessageLink(message); break;
+ case "✏ Edit": StartEdit(message); break;
+ case "🗑 Delete": ConfirmDelete(message); break;
+ }
+ }
+
+ private void AttachChannelContextMenu(View target, ChannelItem channel)
+ {
+#if WINDOWS
+ target.HandlerChanged += (s, e) =>
+ {
+ if (target.Handler?.PlatformView is not Microsoft.UI.Xaml.FrameworkElement fe) return;
+ fe.RightTapped += async (_, args) =>
+ {
+ args.Handled = true;
+ await ShowChannelContextMenuAsync(channel);
+ };
+ };
+#else
+ var longPress = new TapGestureRecognizer { NumberOfTapsRequired = 2 };
+ longPress.Tapped += async (_, _) => await ShowChannelContextMenuAsync(channel);
+ target.GestureRecognizers.Add(longPress);
+#endif
+ }
+
+ private async Task ShowChannelContextMenuAsync(ChannelItem channel)
+ {
+ var options = new List { "⚙ View Permissions" };
+ if (channel.CanManage)
+ options.Add("🗑 Delete Channel");
+
+ var action = await DisplayActionSheet($"#{channel.Name}", "Cancel", null, [.. options]);
+
+ switch (action)
+ {
+ case "⚙ View Permissions":
+ await ShowChannelPermissionsAsync(channel);
+ break;
+ case "🗑 Delete Channel":
+ await ConfirmDeleteChannelAsync(channel);
+ break;
+ }
+ }
+
+ private async Task ShowChannelPermissionsAsync(ChannelItem channel)
+ {
+ var lines = new List
+ {
+ $"Channel: #{channel.Name}",
+ $"Type: {channel.Type}",
+ $"Read-only: {(channel.IsReadOnly ? "Yes (only admins can post)" : "No")}",
+ "",
+ "Working channel permissions:",
+ " • Visibility — who can see the channel",
+ " • Speak — who can talk (voice channels)",
+ " • Edit — rename / reconfigure",
+ " • Delete — remove the channel",
+ "",
+ "Your access here:",
+ $" Post: {(channel.CanPost ? "Yes" : "No")}",
+ $" Manage: {(channel.CanManage ? "Yes" : "No")}",
+ };
+
+ await DisplayAlert("Channel Permissions", string.Join("\n", lines), "Close");
+ }
+
+ private async Task ConfirmDeleteChannelAsync(ChannelItem channel)
+ {
+ bool ok = await DisplayAlert(
+ "Delete Channel",
+ $"Delete #{channel.Name}? This cannot be undone.",
+ "Delete", "Cancel");
+
+ if (ok) _socket.SendDeleteChannel(channel.ChannelId);
+ }
+
+ private async void AddChannel_OnClicked(object? sender, EventArgs e)
+ {
+ var name = await DisplayPromptAsync("New Channel", "Channel name:", "Create", "Cancel",
+ placeholder: "channel-name", maxLength: 32);
+ if (string.IsNullOrWhiteSpace(name)) return;
+
+ var typeStr = await DisplayActionSheet("Channel Type", "Cancel", null,
+ "Text", "Voice", "Forum", "Stage", "File");
+ if (typeStr is null or "Cancel") return;
+
+ var channelType = typeStr switch
+ {
+ "Voice" => ChannelType.Voice,
+ "Forum" => ChannelType.Forum,
+ "Stage" => ChannelType.Stage,
+ "File" => ChannelType.File,
+ _ => ChannelType.Text
+ };
+
+ var group = await DisplayPromptAsync("Group / Category",
+ "Optional group label (e.g. General):", "OK", "Skip",
+ placeholder: "General") ?? string.Empty;
+
+ _socket.SendCreateChannel(
+ name.Trim().ToLower().Replace(" ", "-"),
+ channelType,
+ group);
}
private void HandleChannelList(SocketChannelList channelList)
@@ -119,73 +551,217 @@ public partial class MainPage : ContentPage
_channels.Clear();
_channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt));
- var defaultChannel = _channels
- .Where(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase))
- .OrderBy(c => c.CreatedAt)
- .FirstOrDefault()
- ?? _channels.OrderBy(c => c.CreatedAt).FirstOrDefault();
-
- if (defaultChannel is null) return;
-
- _currentChannelId = defaultChannel.ChannelId;
- _currentChannelName = defaultChannel.Name;
-
- MainThread.BeginInvokeOnMainThread(async () =>
+ if (!_channelsInitialized)
{
- ChannelLabel.Text = $"#{_currentChannelName}";
- RenderChannelList();
- await _rtc.PushRtcContextToJsAsync();
- });
+ _channelsInitialized = true;
- _socket.SendGetHistory(_currentChannelId);
+ var defaultChannel = _channels
+ .FirstOrDefault(c => c.Name.Equals("welcome", StringComparison.OrdinalIgnoreCase))
+ ?? _channels.FirstOrDefault();
+
+ if (defaultChannel is null) return;
+
+ _currentChannelId = defaultChannel.ChannelId;
+ _currentChannelName = defaultChannel.Name;
+ _currentChannelType = defaultChannel.Type;
+
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ ChannelLabel.Text = $"#{_currentChannelName}";
+ RenderChannelList();
+ UpdateInputForCurrentChannel();
+ await _rtc.PushRtcContextToJsAsync();
+ });
+
+ _socket.SendGetHistory(_currentChannelId);
+ }
+ else
+ {
+ MainThread.BeginInvokeOnMainThread(RenderChannelList);
+ }
}
- private void HandleEncryptedChat(SocketEncryptedMessage payload) {
- if (payload.RecipientUsername != _username)
- return;
+ private void HandleEncryptedChat(SocketEncryptedMessage payload)
+ {
+ if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
- string decryptedText;
+ ChatMessage message;
- try
+ if (payload.IsDeleted)
{
- var privateKey = KeyStorage.LoadPrivateKey(_username);
-
- decryptedText = E2EeHelper.DecryptForRecipient(
- new EncryptedPayload
- {
- CipherText = payload.CipherText,
- Nonce = payload.Nonce,
- Tag = payload.Tag,
- EncryptedKey = payload.EncryptedKey
- },
- privateKey
- );
+ message = new ChatMessage
+ {
+ MessageId = payload.MessageId,
+ SenderUsername = payload.SenderUsername,
+ Timestamp = DateTime.MinValue,
+ IsDeleted = true
+ };
}
- catch (Exception ex)
+ else
{
- Console.WriteLine($"[{_username}] failed to decrypt chat: {ex.Message}");
- return;
- }
+ if (!TryDecryptAndParseContent(payload, out var content)) return;
- var message = new ChatMessage
- {
- SenderUsername = payload.SenderUsername,
- Text = decryptedText,
- Timestamp = DateTime.Now
- };
+ message = new ChatMessage
+ {
+ MessageId = payload.MessageId,
+ SenderUsername = payload.SenderUsername,
+ Text = content.Text,
+ Timestamp = DateTime.Now,
+ ReplyToId = content.ReplyToId,
+ ReplyToSenderUsername = content.ReplyToSenderUsername,
+ ReplyPreview = content.ReplyPreview,
+ Mentions = content.Mentions,
+ AttachmentBase64 = content.AttachmentBase64,
+ AttachmentMimeType = content.AttachmentMimeType,
+ AttachmentFileName = content.AttachmentFileName,
+ IsEdited = payload.IsEdited
+ };
+ }
if (!_messagesByChannel.ContainsKey(payload.ChannelId))
_messagesByChannel[payload.ChannelId] = [];
_messagesByChannel[payload.ChannelId].Add(message);
+ if (!string.IsNullOrWhiteSpace(message.MessageId))
+ _messagesById[message.MessageId] = message;
+
if (payload.ChannelId == _currentChannelId)
{
- MainThread.BeginInvokeOnMainThread(() =>
- {
- RenderSingleMessage(message);
- });
+ MainThread.BeginInvokeOnMainThread(() => RenderSingleMessage(message));
}
+ else if (MessageMentionsMe(message) &&
+ !message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase))
+ {
+ _mentionCounts.TryGetValue(payload.ChannelId, out var n);
+ _mentionCounts[payload.ChannelId] = n + 1;
+ MainThread.BeginInvokeOnMainThread(RenderChannelList);
+ }
+ }
+
+ private static bool MessageMentionsMe(ChatMessage message) =>
+ message.Mentions is not null &&
+ message.Mentions.Any(m =>
+ string.Equals(m, _username, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(m, "everyone", StringComparison.OrdinalIgnoreCase));
+
+ private void HandleMessageEdited(SocketEncryptedMessage payload)
+ {
+ if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
+ if (!TryDecryptAndParseContent(payload, out var content)) return;
+ if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return;
+
+ message.Text = content.Text;
+ message.IsEdited = true;
+
+ if (_messageBubbles.TryGetValue(payload.MessageId, out var bubble))
+ MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
+ }
+
+ private void HandleMessageDeleted(SocketMessageDeletedEvent payload)
+ {
+ if (!_messagesById.TryGetValue(payload.MessageId, out var message)) return;
+ message.IsDeleted = true;
+
+ if (_messageBubbles.TryGetValue(payload.MessageId, out var bubble))
+ MainThread.BeginInvokeOnMainThread(() => RebuildBubbleContent(bubble, message));
+ }
+
+ private void HandleTyping(SocketTypingEvent payload)
+ {
+ if (payload.ChannelId != _currentChannelId) return;
+ if (payload.Username.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
+
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ TypingLabel.Text = $"{payload.Username} is typing…";
+ TypingLabel.IsVisible = true;
+ });
+
+ if (_typingClearTimers.TryGetValue(payload.Username, out var existing))
+ existing.Cancel();
+
+ var cts = new CancellationTokenSource();
+ _typingClearTimers[payload.Username] = cts;
+
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(3000, cts.Token);
+ if (!cts.Token.IsCancellationRequested)
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ TypingLabel.IsVisible = false;
+ TypingLabel.Text = string.Empty;
+ });
+ }, cts.Token);
+ }
+
+ private void HandleEditHistory(SocketEditHistoryResponse response)
+ {
+ var entries = new List<(string Text, DateTime At)>();
+
+ foreach (var entry in response.Entries)
+ {
+ try
+ {
+ var pk = KeyStorage.LoadPrivateKey(_username);
+ var plainText = E2EeHelper.DecryptForRecipient(
+ new EncryptedPayload
+ {
+ CipherText = entry.CipherText,
+ Nonce = entry.Nonce,
+ Tag = entry.Tag,
+ EncryptedKey = entry.EncryptedKey
+ }, pk);
+
+ ChatMessageContent? parsed = null;
+ try { parsed = JsonSerializer.Deserialize(plainText); } catch { }
+ entries.Add((parsed?.Text ?? plainText, entry.EditedAt));
+ }
+ catch (Exception ex) { Console.WriteLine($"Edit history decrypt: {ex.Message}"); }
+ }
+
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ if (entries.Count == 0)
+ {
+ await DisplayAlert("Edit History", "No previous versions found.", "OK");
+ return;
+ }
+
+ var text = string.Join("\n\n", entries.Select(e => $"[{e.At:g}]\n{e.Text}"));
+ await DisplayAlert($"Edit History ({entries.Count} versions)", text, "Close");
+ });
+ }
+
+ private bool TryDecryptAndParseContent(SocketEncryptedMessage payload, out ChatMessageContent content)
+ {
+ content = new ChatMessageContent();
+
+ string decryptedText;
+ try
+ {
+ var pk = KeyStorage.LoadPrivateKey(_username);
+ decryptedText = E2EeHelper.DecryptForRecipient(
+ new EncryptedPayload
+ {
+ CipherText = payload.CipherText,
+ Nonce = payload.Nonce,
+ Tag = payload.Tag,
+ EncryptedKey = payload.EncryptedKey
+ }, pk);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{_username}] decrypt failed: {ex.Message}");
+ return false;
+ }
+
+ try { content = JsonSerializer.Deserialize(decryptedText) ?? new ChatMessageContent { Text = decryptedText }; }
+ catch { content = new ChatMessageContent { Text = decryptedText }; }
+
+ return true;
}
protected override void OnDisappearing()
@@ -198,109 +774,311 @@ public partial class MainPage : ContentPage
{
SidebarList.Children.Clear();
- foreach (var channel in _channels.OrderBy(c => c.CreatedAt))
+ var grouped = _channels
+ .OrderBy(c => c.CreatedAt)
+ .GroupBy(c => string.IsNullOrWhiteSpace(c.Group) ? "Channels" : c.Group);
+
+ foreach (var group in grouped)
{
- var button = new ChannelButton
+ SidebarList.Children.Add(new Label
{
- Text = $"#{channel.Name}",
- Type = channel.Type,
- Group = channel.Group
- };
+ Text = group.Key.ToUpper(),
+ FontSize = 10,
+ FontAttributes = FontAttributes.Bold,
+ TextColor = Colors.Gray,
+ Margin = new Thickness(0, 6, 0, 2)
+ });
- button.Clicked += (_, _) =>
+ foreach (var channel in group)
{
- _currentChannelId = channel.ChannelId;
- _currentChannelName = channel.Name;
+ bool isSelected = channel.ChannelId == _currentChannelId;
- MainThread.BeginInvokeOnMainThread(async () =>
+ var prefix = channel.Type switch
{
- await _rtc.PushRtcContextToJsAsync();
+ ChannelType.Voice => "🔊",
+ ChannelType.Forum => "📋",
+ ChannelType.Stage => "🎤",
+ ChannelType.File => "📁",
+ _ => "#"
+ };
- if (channel.Type == ChannelType.Voice)
- {
- if (!RtcView.IsVisible)
- SwapView();
+ var lockSuffix = channel.IsReadOnly ? " 🔒" : string.Empty;
- _ = _rtc.JoinRtcChannel();
- }
- });
+ _mentionCounts.TryGetValue(channel.ChannelId, out var mentionCount);
+ var pingSuffix = (mentionCount > 0 && !isSelected) ? $" 🔔{mentionCount}" : string.Empty;
- ChannelLabel.Text = $"#{_currentChannelName}";
+ var btn = new ChannelButton
+ {
+ Text = $"{prefix} {channel.Name}{lockSuffix}{pingSuffix}",
+ Type = channel.Type,
+ Group = channel.Group,
+ HorizontalOptions = LayoutOptions.Fill,
+ BackgroundColor = isSelected ? Color.FromArgb("#3A3A3A") : Colors.Transparent,
+ FontAttributes = (isSelected || mentionCount > 0) ? FontAttributes.Bold : FontAttributes.None
+ };
+
+ btn.Clicked += (_, _) => OnChannelSelected(channel);
+
+ AttachChannelContextMenu(btn, channel);
+
+ SidebarList.Children.Add(btn);
+ }
+ }
+ }
+
+ private void OnChannelSelected(ChannelItem channel)
+ {
+ _currentChannelId = channel.ChannelId;
+ _currentChannelName = channel.Name;
+ _currentChannelType = channel.Type;
+
+ _mentionCounts.Remove(channel.ChannelId);
+
+ ClearTypingIndicator();
+ CancelContext();
+
+ ChannelLabel.Text = $"#{_currentChannelName}";
+
+ MainThread.BeginInvokeOnMainThread(async () =>
+ {
+ await _rtc.PushRtcContextToJsAsync();
+
+ if (channel.Type == ChannelType.Voice)
+ {
+ MessagesView.IsVisible = false;
+ RtcView.IsVisible = true;
+ InputArea.IsVisible = false;
+ _ = _rtc.JoinRtcChannel();
+ }
+ else
+ {
+ if (RtcView.IsVisible) _rtc.LeaveRtcChannel();
+
+ RtcView.IsVisible = false;
+ MessagesView.IsVisible = true;
+ InputArea.IsVisible = true;
+
+ UpdateInputForCurrentChannel();
RenderCurrentChannelMessages();
if (!_messagesByChannel.ContainsKey(channel.ChannelId))
_socket.SendGetHistory(channel.ChannelId);
- };
+ }
- SidebarList.Children.Add(button);
- }
+ RenderChannelList();
+ });
+ }
+
+ private void UpdateInputForCurrentChannel()
+ {
+ var channel = _channels.FirstOrDefault(c => c.ChannelId == _currentChannelId);
+ if (channel is null) return;
+
+ bool canPost = channel.CanPost;
+ MessageEntry.IsEnabled = canPost;
+ SendButton.IsEnabled = canPost;
+ MessageEntry.Placeholder = canPost
+ ? "Type a message… (Shift+Enter for newline)"
+ : "This channel is read-only — you don't have permission to post here.";
+ }
+
+ private void ClearTypingIndicator()
+ {
+ foreach (var cts in _typingClearTimers.Values) cts.Cancel();
+ _typingClearTimers.Clear();
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ TypingLabel.IsVisible = false;
+ TypingLabel.Text = string.Empty;
+ });
}
private void RenderCurrentChannelMessages()
{
MessagesLayout.Children.Clear();
-
- if (_currentChannelId is null)
- return;
-
- if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages))
- return;
-
- foreach (var message in messages.OrderBy(m => m.Timestamp))
- {
- RenderSingleMessage(message);
- }
+ if (_currentChannelId is null) return;
+ if (!_messagesByChannel.TryGetValue(_currentChannelId, out var messages)) return;
+ foreach (var m in messages.OrderBy(m => m.Timestamp))
+ RenderSingleMessage(m);
}
+ private void SwapView_OnClicked(object? sender, EventArgs e) { }
+
private async void RenderSingleMessage(ChatMessage message)
{
- var isOwnMessage = message.SenderUsername == _username;
+ bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase);
+
+ bool mentionsMe = MessageMentionsMe(message);
var bubble = new Border
{
- StrokeThickness = 1,
- Padding = 10,
- Margin = isOwnMessage
- ? new Thickness(40, 0, 0, 0)
- : new Thickness(0, 0, 40, 0),
- HorizontalOptions = isOwnMessage
- ? LayoutOptions.End
- : LayoutOptions.Start,
- Content = new VerticalStackLayout
- {
- Spacing = 2,
- Children =
- {
- new Label { Text = message.SenderUsername, FontAttributes = FontAttributes.Bold, FontSize = 12 },
- new Label { Text = message.Text, FontSize = 14 },
- new Label { Text = message.Timestamp.ToString("h:mm tt"), FontSize = 10 }
- }
- }
+ StrokeThickness = mentionsMe ? 2 : 1,
+ Stroke = mentionsMe ? new SolidColorBrush(Color.FromArgb("#9EA8FF")) : null,
+ Padding = new Thickness(10),
+ Margin = isOwn ? new Thickness(40, 0, 0, 0) : new Thickness(0, 0, 40, 0),
+ HorizontalOptions = isOwn ? LayoutOptions.End : LayoutOptions.Start,
+ Content = BuildBubbleContent(message)
};
+ AttachMessageContextMenu(bubble, message);
+
+ if (!string.IsNullOrWhiteSpace(message.MessageId))
+ _messageBubbles[message.MessageId] = bubble;
+
MessagesLayout.Children.Add(bubble);
- await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true);
+ await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, animated: true);
}
- private void SwapView()
+ private void RebuildBubbleContent(Border bubble, ChatMessage message)
{
- if (RtcView.IsVisible)
+ bubble.Content = BuildBubbleContent(message);
+ }
+
+ private View BuildBubbleContent(ChatMessage message)
+ {
+ if (message.IsDeleted)
{
- MessagesScrollView.IsVisible = true;
- RtcView.IsVisible = false;
- ViewSwapped.Text = "Swap to Web View";
+ return new Label
+ {
+ Text = "🗑 This message was deleted.",
+ FontAttributes = FontAttributes.Italic,
+ TextColor = Colors.Gray,
+ FontSize = 13
+ };
}
- else
+
+ var stack = new VerticalStackLayout { Spacing = 4 };
+
+ if (message.ReplyToId is not null && message.ReplyPreview is not null)
{
- MessagesScrollView.IsVisible = false;
- RtcView.IsVisible = true;
- ViewSwapped.Text = "Swap to Message View";
+ var quoteTap = new TapGestureRecognizer();
+ quoteTap.Tapped += (_, _) => ScrollToMessage(message.ReplyToId);
+
+ var quote = new Border
+ {
+ StrokeThickness = 0,
+ BackgroundColor = Color.FromArgb("#333333"),
+ Padding = new Thickness(8, 4),
+ Margin = new Thickness(0, 0, 0, 2),
+ Content = new VerticalStackLayout
+ {
+ Spacing = 2,
+ Children =
+ {
+ new Label
+ {
+ Text = $"↩ {message.ReplyToSenderUsername}",
+ FontSize = 11,
+ FontAttributes = FontAttributes.Bold,
+ TextColor = Color.FromArgb("#9ECEFF")
+ },
+ new Label
+ {
+ Text = message.ReplyPreview,
+ FontSize = 12,
+ TextColor = Colors.LightGray,
+ LineBreakMode = LineBreakMode.TailTruncation,
+ MaxLines = 2
+ }
+ }
+ }
+ };
+ quote.GestureRecognizers.Add(quoteTap);
+ stack.Children.Add(quote);
+ }
+
+ stack.Children.Add(new Label
+ {
+ Text = message.SenderUsername,
+ FontAttributes = FontAttributes.Bold,
+ FontSize = 12
+ });
+
+ if (!string.IsNullOrWhiteSpace(message.Text))
+ stack.Children.Add(MarkdownHelper.Render(message.Text));
+
+ if (!string.IsNullOrWhiteSpace(message.AttachmentBase64))
+ {
+ bool isImage = message.AttachmentMimeType?.StartsWith("image/") == true;
+ stack.Children.Add(isImage
+ ? EmbedHelper.BuildBase64ImageEmbed(message.AttachmentBase64, message.AttachmentFileName ?? "image")
+ : EmbedHelper.BuildFileCard(message.AttachmentBase64, message.AttachmentFileName ?? "file", message.AttachmentMimeType ?? "application/octet-stream"));
+ }
+
+ foreach (var embed in EmbedHelper.BuildEmbeds(message.Text))
+ {
+ WireJumpLinks(embed);
+ stack.Children.Add(embed);
+ }
+
+ var footer = new HorizontalStackLayout { Spacing = 6 };
+ footer.Children.Add(new Label
+ {
+ Text = message.Timestamp > DateTime.MinValue ? message.Timestamp.ToString("h:mm tt") : string.Empty,
+ FontSize = 10,
+ TextColor = Colors.Gray
+ });
+
+ if (message.IsEdited)
+ {
+ var editedLabel = new Label
+ {
+ Text = "(edited)",
+ FontSize = 10,
+ FontAttributes = FontAttributes.Italic,
+ TextColor = Colors.Gray,
+ TextDecorations = TextDecorations.Underline
+ };
+ var editedTap = new TapGestureRecognizer();
+ editedTap.Tapped += (_, _) => RequestEditHistory(message);
+ editedLabel.GestureRecognizers.Add(editedTap);
+ footer.Children.Add(editedLabel);
+ }
+
+ stack.Children.Add(footer);
+ return stack;
+ }
+
+ private void WireJumpLinks(View view)
+ {
+ if (view is Label lbl)
+ {
+ var jumpUrl = (string?)lbl.GetValue(EmbedHelper.JumpUrlProperty);
+ if (!string.IsNullOrWhiteSpace(jumpUrl))
+ {
+ var m = Regex.Match(jumpUrl, @"relay://jump/[^/]+/(.+)");
+ if (m.Success)
+ {
+ var msgId = m.Groups[1].Value;
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => ScrollToMessage(msgId);
+ lbl.GestureRecognizers.Clear();
+ lbl.GestureRecognizers.Add(tap);
+ }
+ }
+ }
+ else if (view is Layout layout)
+ {
+ foreach (var child in layout.Children.OfType())
+ WireJumpLinks(child);
+ }
+ else if (view is Border border && border.Content is View borderContent)
+ {
+ WireJumpLinks(borderContent);
}
}
- private void SwapView_OnClicked(object? sender, EventArgs e)
+ private void ScrollToMessage(string messageId)
{
- SwapView();
+ if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return;
+ MainThread.BeginInvokeOnMainThread(async () =>
+ await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true));
+ }
+
+ private void RequestEditHistory(ChatMessage message)
+ {
+ if (string.IsNullOrWhiteSpace(message.MessageId)) return;
+ _socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty);
}
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
@@ -310,29 +1088,39 @@ public partial class MainPage : ContentPage
await _rtc.PushRtcContextToJsAsync();
return;
}
-
- SafeSendRawToWebView($"JS RAW -> C#: {e.Message}");
+ SafeSendRawToWebView($"JS → C#: {e.Message}");
}
private void SafeSendRawToWebView(string message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
- try
- {
- hybridWebView.SendRawMessage(message);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"[{_username}] failed to send raw message to HybridWebView: {ex.Message}");
- }
+ try { hybridWebView.SendRawMessage(message); }
+ catch (Exception ex) { Console.WriteLine($"[{_username}] WebView send failed: {ex.Message}"); }
});
}
+ public class ChatMessage
+ {
+ public string MessageId { get; set; } = string.Empty;
+ public string SenderUsername { get; set; } = string.Empty;
+ public string Text { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; }
+ public string? ReplyToId { get; set; }
+ public string? ReplyToSenderUsername { get; set; }
+ public string? ReplyPreview { get; set; }
+ public List? Mentions { get; set; }
+ public string? AttachmentBase64 { get; set; }
+ public string? AttachmentMimeType { get; set; }
+ public string? AttachmentFileName { get; set; }
+ public bool IsEdited { get; set; }
+ public bool IsDeleted { get; set; }
+ }
+
public class ChannelButton : Button
{
- public ChannelType Type { get; set; }
- public string Group { get; set; } = string.Empty;
+ public ChannelType Type { get; set; }
+ public string Group { get; set; } = string.Empty;
}
[JsonSourceGenerationOptions(WriteIndented = false)]
@@ -341,7 +1129,5 @@ public partial class MainPage : ContentPage
[JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(string))]
- internal partial class HybridJSType : JsonSerializerContext
- {
- }
-}
\ No newline at end of file
+ internal partial class HybridJSType : JsonSerializerContext { }
+}
diff --git a/RelayClient/Services/RelaySocketClient.cs b/RelayClient/Services/RelaySocketClient.cs
index 642dee1..a87aa94 100644
--- a/RelayClient/Services/RelaySocketClient.cs
+++ b/RelayClient/Services/RelaySocketClient.cs
@@ -12,13 +12,18 @@ public sealed class RelaySocketClient
public string? ServerPublicKey { get; private set; }
+ public event Action? RawMessageReceived;
public event Action? ChannelListReceived;
public event Action? EncryptedChatReceived;
+ public event Action? MessageEdited;
+ public event Action? MessageDeleted;
+ public event Action? TypingReceived;
+ public event Action? EditHistoryReceived;
public event Action? EncryptedRtcSignalReceived;
public event Action? ServerPublicKeyReceived;
public event Action? Log;
- public RelaySocketClient(string username, string url = "ws://192.168.1.85:5001/")
+ public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
{
_username = username;
_socket = new WebSocket(url);
@@ -31,151 +36,169 @@ public sealed class RelaySocketClient
var publicKey = KeyStorage.LoadPublicKey(_username);
- SendControlMessage(new WsControlMessage
- {
- Action = WsAction.Authenticate,
- Username = _username,
- Token = MainPage._userToken
- });
-
- SendControlMessage(new WsControlMessage
- {
- Action = WsAction.RegisterKey,
- Username = _username,
- PublicKey = publicKey
- });
-
+ SendControlMessage(new WsControlMessage { Action = WsAction.Authenticate, Username = _username, Token = MainPage._userToken });
+ SendControlMessage(new WsControlMessage { Action = WsAction.RegisterKey, Username = _username, PublicKey = publicKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetServerKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
}
- public void SendGetHistory(string channelId)
- {
- SendControlMessage(new WsControlMessage
- {
- Action = WsAction.GetHistory,
- Username = _username,
- ChannelId = channelId
- });
- }
-
- public void SendRtcJoinChannel(string channelId)
- {
- SendControlMessage(new WsControlMessage
- {
- Action = WsAction.RtcJoin,
- Username = _username,
- ChannelId = channelId
- });
- }
-
- public void SendRtcLeaveChannel(string channelId)
- {
- SendControlMessage(new WsControlMessage
- {
- Action = WsAction.RtcLeave,
- Username = _username,
- ChannelId = channelId
- });
- }
-
- private void SendControlMessage(WsControlMessage msg)
- {
- SendJson(msg);
- }
-
- public void SendJson(T payload)
- {
- var json = JsonSerializer.Serialize(payload);
-
- if (_socket.ReadyState == WebSocketState.Open)
- _socket.Send(json);
- }
-
public void Disconnect()
{
_socket.OnMessage -= OnMessage;
-
if (_socket.ReadyState == WebSocketState.Open)
_socket.Close();
}
+ public void SendControlMessage(WsControlMessage message) =>
+ SendRaw(JsonSerializer.Serialize(message));
+
+ public void SendGetHistory(string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.GetHistory, Username = _username, ChannelId = channelId });
+
+ public void SendRtcJoinChannel(string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.RtcJoin, Username = _username, ChannelId = channelId });
+
+ public void SendRtcLeaveChannel(string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.RtcLeave, Username = _username, ChannelId = channelId });
+
+ public void SendTyping(string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.SendTyping, Username = _username, ChannelId = channelId });
+
+ public void SendGetEditHistory(string messageId, string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.GetEditHistory, Username = _username, MessageId = messageId, ChannelId = channelId });
+
+ public void SendCreateChannel(string name, ChannelType type, string group = "") =>
+ SendControlMessage(new WsControlMessage
+ {
+ Action = WsAction.CreateChannel,
+ ChannelName = name,
+ ChannelType = (int)type,
+ ChannelGroup = group
+ });
+
+ public void SendDeleteChannel(string channelId) =>
+ SendControlMessage(new WsControlMessage { Action = WsAction.DeleteChannel, ChannelId = channelId });
+
+ public void SendEditMessage(string messageId, string channelId, EncryptedPayload encrypted) =>
+ SendJson(new SocketEncryptedMessage
+ {
+ Type = SignalType.ClientEditMessage, MessageId = messageId,
+ SenderUsername = _username, ChannelId = channelId,
+ CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
+ Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey
+ });
+
+ public void SendDeleteMessage(string messageId, string channelId) =>
+ SendJson(new SocketEncryptedMessage
+ {
+ Type = SignalType.ClientDeleteMessage, MessageId = messageId,
+ SenderUsername = _username, ChannelId = channelId
+ });
+
+ public void SendRaw(string message)
+ {
+ if (_socket.ReadyState != WebSocketState.Open)
+ {
+ Log?.Invoke($"[{_username}] Drop: socket not open ({_socket.ReadyState}), {message.Length} bytes.");
+ return;
+ }
+
+ try
+ {
+ _socket.Send(message);
+ }
+ catch (Exception ex)
+ {
+ Log?.Invoke($"[{_username}] Send failed ({message.Length} bytes): {ex.Message}");
+ throw;
+ }
+ }
+
+ public void SendJson(T payload) => SendRaw(JsonSerializer.Serialize(payload));
+
private void OnMessage(object? sender, MessageEventArgs e)
{
- Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}");
+ RawMessageReceived?.Invoke(e.Data);
+ Log?.Invoke($"[{_username}] RAW: {e.Data[..Math.Min(200, e.Data.Length)]}");
try
{
using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement;
- if (root.TryGetProperty("Event", out var eventProp))
+ if (root.TryGetProperty("Event", out var evEl))
{
- var wsEvent = (WsEvent)eventProp.GetInt32();
-
+ var wsEvent = (WsEvent)evEl.GetInt32();
switch (wsEvent)
{
- case WsEvent.Authenticated:
- Log?.Invoke($"[{_username}] Authenticated.");
- return;
- case WsEvent.KeyRegistered:
- Log?.Invoke($"[{_username}] Key registered.");
- return;
+ case WsEvent.KeyRegistered: Log?.Invoke($"[{_username}] Key registered."); return;
+ case WsEvent.Authenticated: Log?.Invoke($"[{_username}] Authenticated."); return;
case WsEvent.Error:
- var detail = root.TryGetProperty("Detail", out var d) ? d.GetString() : null;
- Log?.Invoke($"[{_username}] Server error: {detail}");
+ var det = root.TryGetProperty("Detail", out var d) ? d.GetString() : null;
+ Log?.Invoke($"[{_username}] Server error: {det}");
return;
}
-
return;
}
- if (!root.TryGetProperty("Type", out var typeElement))
- return;
-
- var type = (SignalType)typeElement.GetInt32();
+ if (!root.TryGetProperty("Type", out var typeEl)) return;
+ var type = (SignalType)typeEl.GetInt32();
switch (type)
{
case SignalType.ChannelList:
{
- var channelList = JsonSerializer.Deserialize(e.Data);
- if (channelList is not null)
- ChannelListReceived?.Invoke(channelList);
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) ChannelListReceived?.Invoke(p);
return;
}
-
case SignalType.ServerPublicKey:
{
- var serverKeyMessage = JsonSerializer.Deserialize(e.Data);
- if (serverKeyMessage is not null)
- {
- ServerPublicKey = serverKeyMessage.PublicKey;
- ServerPublicKeyReceived?.Invoke(serverKeyMessage.PublicKey);
- }
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) { ServerPublicKey = p.PublicKey; ServerPublicKeyReceived?.Invoke(p.PublicKey); }
return;
}
-
case SignalType.EncryptedSignal:
{
- var payload = JsonSerializer.Deserialize(e.Data);
- if (payload is not null)
- EncryptedRtcSignalReceived?.Invoke(payload);
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) EncryptedRtcSignalReceived?.Invoke(p);
return;
}
-
case SignalType.EncryptedChat:
{
- var payload = JsonSerializer.Deserialize(e.Data);
- if (payload is not null)
- EncryptedChatReceived?.Invoke(payload);
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) EncryptedChatReceived?.Invoke(p);
+ return;
+ }
+ case SignalType.MessageEdited:
+ {
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) MessageEdited?.Invoke(p);
+ return;
+ }
+ case SignalType.MessageDeleted:
+ {
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) MessageDeleted?.Invoke(p);
+ return;
+ }
+ case SignalType.TypingIndicator:
+ {
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) TypingReceived?.Invoke(p);
+ return;
+ }
+ case SignalType.EditHistory:
+ {
+ var p = JsonSerializer.Deserialize(e.Data);
+ if (p is not null) EditHistoryReceived?.Invoke(p);
return;
}
}
}
catch (Exception ex)
{
- Log?.Invoke($"[{_username}] failed to process websocket message: {ex.Message}");
+ Log?.Invoke($"[{_username}] WS parse error: {ex.Message}");
}
}
}
diff --git a/RelayServer/Models/Chat/ChannelMessageEdits.cs b/RelayServer/Models/Chat/ChannelMessageEdits.cs
new file mode 100644
index 0000000..7f70f99
--- /dev/null
+++ b/RelayServer/Models/Chat/ChannelMessageEdits.cs
@@ -0,0 +1,12 @@
+using SurrealDb.Net.Models;
+
+namespace RelayServer.Models;
+
+public class ChannelMessageEdits : Record
+{
+ public required string MessageId { get; set; }
+ public required string CipherText { get; set; }
+ public required string Nonce { get; set; }
+ public required string Tag { get; set; }
+ public required DateTime EditedAt { get; set; }
+}
diff --git a/RelayServer/Models/Chat/ChannelMessages.cs b/RelayServer/Models/Chat/ChannelMessages.cs
index e397d2e..bfa69dd 100644
--- a/RelayServer/Models/Chat/ChannelMessages.cs
+++ b/RelayServer/Models/Chat/ChannelMessages.cs
@@ -10,4 +10,6 @@ public class ChannelMessages : Record
public required string Nonce { get; set; }
public required string Tag { get; set; }
public required DateTime CreatedAt { get; set; }
-}
\ No newline at end of file
+ public DateTime? EditedAt { get; set; }
+ public bool IsDeleted { get; set; }
+}
diff --git a/RelayServer/Models/Chat/Channels.cs b/RelayServer/Models/Chat/Channels.cs
index 10ad94a..5abf9e9 100644
--- a/RelayServer/Models/Chat/Channels.cs
+++ b/RelayServer/Models/Chat/Channels.cs
@@ -1,4 +1,5 @@
using SurrealDb.Net.Models;
+using RelayShared.Services;
namespace RelayServer.Models;
@@ -6,4 +7,9 @@ public class Channels : Record
{
public required string Name { get; set; }
public required DateTime CreatedAt { get; set; }
-}
\ No newline at end of file
+ public ChannelType Type { get; set; } = ChannelType.Text;
+ public string Group { get; set; } = string.Empty;
+ public bool IsReadOnly { get; set; }
+ public bool IsDeleted { get; set; }
+ public string? LinkedFileChannelId { get; set; }
+}
diff --git a/RelayServer/Models/Server/ChannelPermissions.cs b/RelayServer/Models/Server/ChannelPermissions.cs
new file mode 100644
index 0000000..3b2e883
--- /dev/null
+++ b/RelayServer/Models/Server/ChannelPermissions.cs
@@ -0,0 +1,11 @@
+using SurrealDb.Net.Models;
+
+namespace RelayServer.Models;
+
+public class ChannelPermissions : Record
+{
+ public required string ChannelId { get; set; }
+ public required string RoleId { get; set; }
+ public PermissionFlags Allow { get; set; }
+ public PermissionFlags Deny { get; set; }
+}
diff --git a/RelayServer/Models/Server/Roles.cs b/RelayServer/Models/Server/Roles.cs
new file mode 100644
index 0000000..edb7087
--- /dev/null
+++ b/RelayServer/Models/Server/Roles.cs
@@ -0,0 +1,28 @@
+using SurrealDb.Net.Models;
+
+namespace RelayServer.Models;
+
+[Flags]
+public enum PermissionFlags
+{
+ None = 0,
+ ReadMessages = 1 << 0,
+ SendMessages = 1 << 1,
+ ManageMessages = 1 << 2, // Edit / delete others' messages
+ ManageChannels = 1 << 3, // Create channels (umbrella manage permission)
+ ManageMembers = 1 << 4, // Kick / ban members
+ Administrator = 1 << 5, // All permissions, bypasses channel overrides
+ ViewChannel = 1 << 6, // "Visibility" — can see the channel at all
+ Speak = 1 << 7, // Can transmit in a voice channel
+ EditChannel = 1 << 8, // Rename / reconfigure a channel
+ DeleteChannel = 1 << 9 // Delete a channel
+}
+
+public class Roles : Record
+{
+ public required string Name { get; set; }
+ public required PermissionFlags Permissions { get; set; }
+ public required DateTime CreatedAt { get; set; }
+
+ public int Priority { get; set; }
+}
diff --git a/RelayServer/Models/Server/UserRoles.cs b/RelayServer/Models/Server/UserRoles.cs
new file mode 100644
index 0000000..4b63a8f
--- /dev/null
+++ b/RelayServer/Models/Server/UserRoles.cs
@@ -0,0 +1,10 @@
+using SurrealDb.Net.Models;
+
+namespace RelayServer.Models;
+
+public class UserRoles : Record
+{
+ public required string UserId { get; set; }
+ public required string RoleId { get; set; }
+ public required DateTime AssignedAt { get; set; }
+}
diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs
index 07d071f..319548b 100644
--- a/RelayServer/Program.cs
+++ b/RelayServer/Program.cs
@@ -14,6 +14,7 @@ var cryptoService = new ChannelCryptoService();
await using var db = await surrealService.ConnectAsync();
ChatSocketBehavior.ClientKeyService = new ClientKeyService(db);
+ChatSocketBehavior.PermissionService = new PermissionService(db);
ChatSocketBehavior.Db = db;
ChatSocketBehavior.ChannelCryptoService = cryptoService;
diff --git a/RelayServer/Services/Chat/ChatSocketBehavior.cs b/RelayServer/Services/Chat/ChatSocketBehavior.cs
index 0756a8c..4b3aafa 100644
--- a/RelayServer/Services/Chat/ChatSocketBehavior.cs
+++ b/RelayServer/Services/Chat/ChatSocketBehavior.cs
@@ -11,9 +11,14 @@ using RelayShared.Services;
namespace RelayServer.Services.Chat;
+///
+/// Handles all WebSocket traffic: authentication, key registration, channel management,
+/// encrypted chat relay, message editing/deletion, typing indicators, and edit history.
+///
public class ChatSocketBehavior : WebSocketBehavior
{
public static ClientKeyService? ClientKeyService { get; set; }
+ public static PermissionService? PermissionService { get; set; }
public static string? ServerPublicKey { get; set; }
public static string? ServerPrivateKey { get; set; }
public static string? ChannelDbKey { get; set; }
@@ -40,532 +45,717 @@ public class ChatSocketBehavior : WebSocketBehavior
if (root.TryGetProperty("Type", out var typeProp))
{
var type = (SignalType)typeProp.GetInt32();
-
switch (type)
{
- case SignalType.EncryptedSignal:
- HandleEncryptedRtcSignal(msg);
- return;
- case SignalType.ClientEncryptedChat:
- HandleEncryptedChatMessage(msg);
- return;
+ case SignalType.EncryptedSignal: HandleEncryptedRtcSignal(msg); return;
+ case SignalType.ClientEncryptedChat: HandleEncryptedChatMessage(msg); return;
+ case SignalType.ClientEditMessage: HandleEditMessage(msg); return;
+ case SignalType.ClientDeleteMessage: HandleDeleteMessage(msg); return;
}
}
+
+ Console.WriteLine($"Unrecognised WS message session={ID}: {msg[..Math.Min(120, msg.Length)]}");
}
catch (Exception ex)
{
- Console.WriteLine($"WebSocket message error: session={ID}, error={ex.Message}");
+ Console.WriteLine($"WS message error session={ID}: {ex.Message}");
}
}
- private void DispatchControl(WsAction action, WsControlMessage control)
+ private void DispatchControl(WsAction action, WsControlMessage c)
{
switch (action)
{
- case WsAction.Authenticate:
- HandleAuthenticate(control);
- break;
- case WsAction.RegisterKey:
- HandleRegisterKey(control);
- break;
- case WsAction.GetServerKey:
- HandleGetServerKey();
- break;
- case WsAction.GetChannels:
- HandleGetChannels();
- break;
- case WsAction.GetHistory:
- HandleGetHistory(control);
- break;
- case WsAction.RtcJoin:
- HandleRtcJoinChannel(control);
- break;
- case WsAction.RtcLeave:
- HandleRtcLeaveChannel(control);
- break;
+ case WsAction.Authenticate: HandleAuthenticate(c); break;
+ case WsAction.RegisterKey: HandleRegisterKey(c); break;
+ case WsAction.GetServerKey: HandleGetServerKey(); break;
+ case WsAction.GetChannels: HandleGetChannels(); break;
+ case WsAction.GetHistory: HandleGetHistory(c); break;
+ case WsAction.RtcJoin: HandleRtcJoinChannel(c); break;
+ case WsAction.RtcLeave: HandleRtcLeaveChannel(c); break;
+ case WsAction.SendTyping: HandleTyping(c); break;
+ case WsAction.GetEditHistory: HandleGetEditHistory(c); break;
+ case WsAction.CreateChannel: HandleCreateChannel(c); break;
+ case WsAction.DeleteChannel: HandleDeleteChannel(c); break;
+ default: Console.WriteLine($"Unknown WsAction {action} session={ID}"); break;
}
}
+ private async void HandleAuthenticate(WsControlMessage c)
+ {
+ if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token))
+ {
+ Console.WriteLine("Invalid Authenticate payload.");
+ return;
+ }
+
+ try
+ {
+ using var core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337") };
+ core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ core.DefaultRequestHeaders.Add("User-Agent", "RelayServer");
+
+ var resp = await core.PostAsJsonAsync("/server/verify/user",
+ new AuthUserVerify { Username = c.Username, Token = c.Token });
+
+ Console.WriteLine($"Auth [{c.Username}]: {await resp.Content.ReadAsStringAsync()}");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Auth failed for {c.Username}: {ex.Message}");
+ }
+
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username }));
+ }
+
+ private void HandleRegisterKey(WsControlMessage c)
+ {
+ if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey))
+ {
+ Console.WriteLine("Invalid RegisterKey payload.");
+ return;
+ }
+
+ if (ClientKeyService is null) { Console.WriteLine("ClientKeyService null."); return; }
+
+ RegisterOrUpdateClientKeySync(c.Username, c.PublicKey);
+ ConnectedClientService.Register(ID, c.Username);
+
+ Console.WriteLine($"Key registered: {c.Username} (session={ID})");
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username }));
+ }
+
+ private void HandleGetServerKey()
+ {
+ if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
+ Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
+ }
+
+ private void HandleGetChannels()
+ {
+ if (Db is null) { Console.WriteLine("Db null."); return; }
+
+ // Resolve the requesting user so we can compute per-user CanPost for each channel.
+ var username = ConnectedClientService.GetUsernameForSession(ID) ?? string.Empty;
+
+ var channels = BuildChannelListForUser(username);
+ Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }));
+ }
+
+ private void HandleGetHistory(WsControlMessage c)
+ {
+ if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
+ {
+ Console.WriteLine("Invalid GetHistory payload.");
+ return;
+ }
+
+ if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return;
+
+ var targetClient = GetClientPublicKeyByUsernameSync(c.Username);
+ if (targetClient is null) { Console.WriteLine($"No public key for history user {c.Username}"); return; }
+
+ var messages = GetChannelMessagesSync()
+ .Where(m => m.ChannelId == c.ChannelId)
+ .OrderBy(m => m.CreatedAt)
+ .ToList();
+
+ Console.WriteLine($"Sending {messages.Count} history messages to {c.Username}");
+
+ foreach (var dbMsg in messages)
+ {
+ var msgId = GetRecordId(dbMsg.Id);
+
+ if (dbMsg.IsDeleted)
+ {
+ Send(JsonSerializer.Serialize(new SocketEncryptedMessage
+ {
+ Type = SignalType.EncryptedChat, MessageId = msgId,
+ SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId),
+ RecipientUsername = c.Username, ChannelId = c.ChannelId, IsDeleted = true
+ }));
+ continue;
+ }
+
+ string plainText;
+ try { plainText = ChannelCryptoService.Decrypt(dbMsg.CipherText, dbMsg.Nonce, dbMsg.Tag, ChannelDbKey); }
+ catch (Exception ex) { Console.WriteLine($"History decrypt failed {dbMsg.Id}: {ex.Message}"); continue; }
+
+ var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
+ Send(JsonSerializer.Serialize(new SocketEncryptedMessage
+ {
+ Type = SignalType.EncryptedChat, MessageId = msgId,
+ SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId),
+ RecipientUsername = c.Username, ChannelId = c.ChannelId,
+ CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
+ Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey,
+ IsEdited = dbMsg.EditedAt.HasValue
+ }));
+ }
+ }
+
+ private void HandleRtcJoinChannel(WsControlMessage c)
+ {
+ if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
+ {
+ Console.WriteLine("Invalid RtcJoin payload.");
+ return;
+ }
+
+ if (PermissionService is not null &&
+ !PermissionService.CanSpeakAsync(c.Username, c.ChannelId).GetAwaiter().GetResult())
+ {
+ Console.WriteLine($"RTC join denied (no Speak): user={c.Username}, channel={c.ChannelId}");
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You don't have permission to speak in this channel." }));
+ return;
+ }
+
+ RtcChannelPresenceService.SetUser(ID, c.Username);
+ RtcChannelPresenceService.JoinChannel(ID, c.ChannelId);
+ Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}");
+ }
+
+ private void HandleRtcLeaveChannel(WsControlMessage c)
+ {
+ if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId))
+ RtcChannelPresenceService.LeaveChannel(ID);
+ Console.WriteLine($"RTC leave: session={ID}, user={c.Username}");
+ }
+
+ private void HandleTyping(WsControlMessage c)
+ {
+ var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(senderUsername) || string.IsNullOrWhiteSpace(c.ChannelId)) return;
+
+ var json = JsonSerializer.Serialize(new SocketTypingEvent
+ {
+ Type = SignalType.TypingIndicator,
+ Username = senderUsername,
+ ChannelId = c.ChannelId
+ });
+
+ foreach (var member in GetServerMembersSync())
+ {
+ var rawUsername = ExtractUsernameFromUserId(member.UserId);
+ if (string.Equals(rawUsername, senderUsername, StringComparison.OrdinalIgnoreCase)) continue;
+
+ foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername))
+ Sessions.SendTo(json, sid);
+ }
+ }
+
+ private void HandleGetEditHistory(WsControlMessage c)
+ {
+ if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
+ if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return;
+
+ var targetClient = GetClientPublicKeyByUsernameSync(c.Username);
+ if (targetClient is null) return;
+
+ var edits = GetChannelMessageEditsSync(c.MessageId)
+ .OrderBy(e => e.EditedAt)
+ .ToList();
+
+ var entries = new List();
+
+ foreach (var edit in edits)
+ {
+ string plainText;
+ try { plainText = ChannelCryptoService.Decrypt(edit.CipherText, edit.Nonce, edit.Tag, ChannelDbKey); }
+ catch (Exception ex) { Console.WriteLine($"Edit history decrypt failed: {ex.Message}"); continue; }
+
+ var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
+ entries.Add(new SocketEditHistoryEntry
+ {
+ CipherText = encrypted.CipherText,
+ Nonce = encrypted.Nonce,
+ Tag = encrypted.Tag,
+ EncryptedKey = encrypted.EncryptedKey,
+ EditedAt = edit.EditedAt
+ });
+ }
+
+ Send(JsonSerializer.Serialize(new SocketEditHistoryResponse
+ {
+ Type = SignalType.EditHistory,
+ MessageId = c.MessageId,
+ Entries = entries
+ }));
+ }
+
+ private void HandleCreateChannel(WsControlMessage c)
+ {
+ var username = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(username)) return;
+
+ if (PermissionService is null || !PermissionService.CanManageChannelsAsync(username).GetAwaiter().GetResult())
+ {
+ Console.WriteLine($"CreateChannel denied for {username}: insufficient permissions.");
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." }));
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(c.ChannelName))
+ {
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Channel name is required." }));
+ return;
+ }
+
+ var type = (ChannelType)c.ChannelType;
+
+ Task.Run(async () => await Db!.Create("channels", new Channels
+ {
+ Name = c.ChannelName,
+ Type = type,
+ Group = c.ChannelGroup ?? string.Empty,
+ CreatedAt = DateTime.UtcNow
+ })).GetAwaiter().GetResult();
+
+ Console.WriteLine($"Channel created: {c.ChannelName} ({type}) by {username}");
+ BroadcastChannelList();
+ }
+
+ private void HandleDeleteChannel(WsControlMessage c)
+ {
+ var username = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(username)) return;
+
+ if (PermissionService is null || !PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult())
+ {
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." }));
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(c.ChannelId)) return;
+
+ var all = GetChannelsSync();
+ var target = all.FirstOrDefault(ch => GetRecordId(ch.Id) == c.ChannelId);
+ if (target is null) return;
+
+ target.IsDeleted = true;
+ Task.Run(async () => await Db!.Merge(target))
+ .GetAwaiter().GetResult();
+
+ Console.WriteLine($"Channel deleted: {target.Name} by {username}");
+ BroadcastChannelList();
+ }
+
+ private void HandleEncryptedRtcSignal(string msg)
+ {
+ SocketRtcSignalMessage? payload;
+ try { payload = JsonSerializer.Deserialize(msg); }
+ catch { Console.WriteLine("Failed to parse RTC signal."); return; }
+
+ if (payload is null || string.IsNullOrWhiteSpace(payload.ChannelId)) return;
+
+ string plainText;
+ try
+ {
+ plainText = E2EeHelper.DecryptForRecipient(
+ new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey },
+ ServerPrivateKey);
+ }
+ catch (Exception ex) { Console.WriteLine($"RTC decrypt failed: {ex.Message}"); return; }
+
+ foreach (var sid in RtcChannelPresenceService.GetSessionsInChannel(payload.ChannelId))
+ {
+ if (sid == ID) continue;
+ var uname = RtcChannelPresenceService.GetUsernameForSession(sid);
+ if (string.IsNullOrWhiteSpace(uname)) continue;
+ var key = GetClientPublicKeyByUsernameSync(uname);
+ if (key is null) continue;
+ var enc = E2EeHelper.EncryptForRecipient(plainText, key.PublicKey);
+ Sessions.SendTo(JsonSerializer.Serialize(new SocketRtcSignalMessage
+ {
+ Type = SignalType.EncryptedSignal, SenderUsername = payload.SenderUsername,
+ ChannelId = payload.ChannelId, CipherText = enc.CipherText,
+ Nonce = enc.Nonce, Tag = enc.Tag, EncryptedKey = enc.EncryptedKey
+ }), sid);
+ }
+ }
+
+ private void HandleEncryptedChatMessage(string msg)
+ {
+ SocketEncryptedMessage? payload;
+ try { payload = JsonSerializer.Deserialize(msg); }
+ catch { Console.WriteLine("Failed to parse chat payload."); return; }
+
+ if (payload is null || payload.Type != SignalType.ClientEncryptedChat) return;
+ if (!EnsureCoreReady() || !EnsureCryptoReady()) return;
+
+ // Permission check.
+ var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(senderUsername)) return;
+
+ if (PermissionService is not null &&
+ !PermissionService.CanSendMessagesAsync(senderUsername, payload.ChannelId).GetAwaiter().GetResult())
+ {
+ Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You cannot send messages in this channel." }));
+ return;
+ }
+
+ string plainText;
+ try
+ {
+ plainText = E2EeHelper.DecryptForRecipient(
+ new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey },
+ ServerPrivateKey);
+ }
+ catch (Exception ex) { Console.WriteLine($"Chat decrypt failed: {ex.Message}"); return; }
+
+ Console.WriteLine($"Decrypted chat from {payload.SenderUsername}");
+
+ string messageId;
+ try
+ {
+ var dbEnc = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey);
+ var saved = CreateChannelMessageSync(new ChannelMessages
+ {
+ ChannelId = payload.ChannelId,
+ SenderUserId = $"users:{payload.SenderUsername.ToLower()}",
+ CipherText = dbEnc.cipherText,
+ Nonce = dbEnc.nonce,
+ Tag = dbEnc.tag,
+ CreatedAt = DateTime.UtcNow
+ });
+ messageId = GetRecordId(saved.Id);
+ Console.WriteLine($"Message saved: {messageId}");
+ }
+ catch (Exception ex) { Console.WriteLine($"Save failed: {ex.Message}"); return; }
+
+ DeliverToServerMembers(plainText, payload.SenderUsername, payload.ChannelId,
+ messageId, SignalType.EncryptedChat, isEdited: false);
+
+ MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
+ }
+
+ private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
+ {
+ ChatMessageContent? content;
+ try { content = JsonSerializer.Deserialize(plainText); }
+ catch { return; }
+
+ if (content is null || string.IsNullOrWhiteSpace(content.AttachmentBase64))
+ return;
+
+ // The user wants images, zips, docs — but not gifs (and links/text aren't attachments anyway).
+ var mime = content.AttachmentMimeType ?? string.Empty;
+ if (mime.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var origin = GetChannelsSync().FirstOrDefault(ch => GetRecordId(ch.Id) == originChannelId);
+ if (origin?.LinkedFileChannelId is null) return;
+
+ var fileChannelId = origin.LinkedFileChannelId;
+
+ if (originChannelId == fileChannelId) return;
+
+ var mirror = new ChatMessageContent
+ {
+ Text = $"📎 Shared from #{origin.Name} by {senderUsername}",
+ AttachmentBase64 = content.AttachmentBase64,
+ AttachmentMimeType = content.AttachmentMimeType,
+ AttachmentFileName = content.AttachmentFileName
+ };
+
+ var mirrorPlain = JsonSerializer.Serialize(mirror);
+
+ string mirrorId;
+ try
+ {
+ var dbEnc = ChannelCryptoService!.Encrypt(mirrorPlain, ChannelDbKey);
+ var saved = CreateChannelMessageSync(new ChannelMessages
+ {
+ ChannelId = fileChannelId,
+ SenderUserId = $"users:{senderUsername.ToLower()}",
+ CipherText = dbEnc.cipherText,
+ Nonce = dbEnc.nonce,
+ Tag = dbEnc.tag,
+ CreatedAt = DateTime.UtcNow
+ });
+ mirrorId = GetRecordId(saved.Id);
+ }
+ catch (Exception ex) { Console.WriteLine($"File mirror save failed: {ex.Message}"); return; }
+
+ DeliverToServerMembers(mirrorPlain, senderUsername, fileChannelId,
+ mirrorId, SignalType.EncryptedChat, isEdited: false);
+
+ Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
+ }
+
+ private void HandleEditMessage(string msg)
+ {
+ SocketEncryptedMessage? request;
+ try { request = JsonSerializer.Deserialize(msg); }
+ catch { Console.WriteLine("Failed to parse edit request."); return; }
+
+ if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return;
+ if (!EnsureCoreReady() || !EnsureCryptoReady()) return;
+
+ var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(senderUsername)) return;
+
+ var existing = GetChannelMessageByIdSync(request.MessageId);
+ if (existing is null) { Console.WriteLine($"Edit: message {request.MessageId} not found."); return; }
+
+ if (!string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($"Edit denied: {senderUsername} does not own {request.MessageId}.");
+ return;
+ }
+
+ string newPlainText;
+ try
+ {
+ newPlainText = E2EeHelper.DecryptForRecipient(
+ new EncryptedPayload { CipherText = request.CipherText, Nonce = request.Nonce, Tag = request.Tag, EncryptedKey = request.EncryptedKey },
+ ServerPrivateKey);
+ }
+ catch (Exception ex) { Console.WriteLine($"Edit decrypt failed: {ex.Message}"); return; }
+
+ try
+ {
+ CreateChannelMessageEditSync(new ChannelMessageEdits
+ {
+ MessageId = request.MessageId,
+ CipherText = existing.CipherText,
+ Nonce = existing.Nonce,
+ Tag = existing.Tag,
+ EditedAt = existing.EditedAt ?? existing.CreatedAt
+ });
+ }
+ catch (Exception ex) { Console.WriteLine($"Edit history save failed: {ex.Message}"); }
+
+ try
+ {
+ var dbEnc = ChannelCryptoService!.Encrypt(newPlainText, ChannelDbKey);
+ existing.CipherText = dbEnc.cipherText;
+ existing.Nonce = dbEnc.nonce;
+ existing.Tag = dbEnc.tag;
+ existing.EditedAt = DateTime.UtcNow;
+ UpdateChannelMessageSync(existing);
+ Console.WriteLine($"Message {request.MessageId} edited by {senderUsername}.");
+ }
+ catch (Exception ex) { Console.WriteLine($"Edit DB update failed: {ex.Message}"); return; }
+
+ DeliverToServerMembers(newPlainText, senderUsername, request.ChannelId,
+ request.MessageId, SignalType.MessageEdited, isEdited: true);
+ }
+
+ private void HandleDeleteMessage(string msg)
+ {
+ SocketEncryptedMessage? request;
+ try { request = JsonSerializer.Deserialize(msg); }
+ catch { Console.WriteLine("Failed to parse delete request."); return; }
+
+ if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return;
+ if (!EnsureCoreReady()) return;
+
+ var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
+ if (string.IsNullOrWhiteSpace(senderUsername)) return;
+
+ var existing = GetChannelMessageByIdSync(request.MessageId);
+ if (existing is null) return;
+
+ bool isOwner = string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase);
+ bool canManage = PermissionService?.CanManageMessagesAsync(senderUsername, request.ChannelId).GetAwaiter().GetResult() ?? false;
+
+ if (!isOwner && !canManage)
+ {
+ Console.WriteLine($"Delete denied: {senderUsername} does not own {request.MessageId}.");
+ return;
+ }
+
+ try
+ {
+ existing.IsDeleted = true;
+ UpdateChannelMessageSync(existing);
+ Console.WriteLine($"Message {request.MessageId} deleted by {senderUsername}.");
+ }
+ catch (Exception ex) { Console.WriteLine($"Delete DB update failed: {ex.Message}"); return; }
+
+ var deletedEvent = JsonSerializer.Serialize(new SocketMessageDeletedEvent
+ {
+ Type = SignalType.MessageDeleted, MessageId = request.MessageId, ChannelId = request.ChannelId
+ });
+
+ foreach (var member in GetServerMembersSync())
+ {
+ var rawUsername = ExtractUsernameFromUserId(member.UserId);
+ foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername))
+ Sessions.SendTo(deletedEvent, sid);
+ }
+ }
+
+ private void DeliverToServerMembers(
+ string plainText, string senderUsername, string channelId,
+ string messageId, SignalType signalType, bool isEdited)
+ {
+ foreach (var member in GetServerMembersSync())
+ {
+ var rawUsername = ExtractUsernameFromUserId(member.UserId);
+ var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
+ if (sessionIds.Count == 0) continue;
+
+ var properUsername = sessionIds
+ .Select(ConnectedClientService.GetUsernameForSession)
+ .FirstOrDefault(u => u is not null) ?? rawUsername;
+
+ var clientKey = GetClientPublicKeyByUsernameSync(properUsername);
+ if (clientKey is null) { Console.WriteLine($"No public key for {properUsername}, skipping."); continue; }
+
+ var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
+ var json = JsonSerializer.Serialize(new SocketEncryptedMessage
+ {
+ Type = signalType, MessageId = messageId,
+ SenderUsername = senderUsername, RecipientUsername = properUsername, ChannelId = channelId,
+ CipherText = encrypted.CipherText, Nonce = encrypted.Nonce,
+ Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey,
+ IsEdited = isEdited
+ });
+
+ foreach (var sid in sessionIds)
+ Sessions.SendTo(json, sid);
+ }
+ }
+
+ private void BroadcastChannelList()
+ {
+ foreach (var member in GetServerMembersSync())
+ {
+ var rawUsername = ExtractUsernameFromUserId(member.UserId);
+ var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
+ if (sessionIds.Count == 0) continue;
+
+ var properUsername = sessionIds
+ .Select(ConnectedClientService.GetUsernameForSession)
+ .FirstOrDefault(u => u is not null) ?? rawUsername;
+
+ var channels = BuildChannelListForUser(properUsername);
+ var json = JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels });
+
+ foreach (var sid in sessionIds)
+ Sessions.SendTo(json, sid);
+ }
+ }
+
+ private List BuildChannelListForUser(string username)
+ {
+ var rawChannels = GetChannelsSync()
+ .Where(c => !c.IsDeleted)
+ .OrderBy(c => c.CreatedAt)
+ .ToList();
+
+ var items = new List();
+
+ foreach (var c in rawChannels)
+ {
+ var channelId = GetRecordId(c.Id);
+
+ // "Visibility" — drop channels this user is not allowed to see.
+ if (PermissionService is not null &&
+ !PermissionService.CanViewChannelAsync(username, channelId).GetAwaiter().GetResult())
+ continue;
+
+ bool canPost = PermissionService is null
+ || PermissionService.CanSendMessagesAsync(username, channelId).GetAwaiter().GetResult();
+ bool canManage = PermissionService is not null &&
+ (PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult() ||
+ PermissionService.CanEditChannelAsync(username).GetAwaiter().GetResult());
+
+ items.Add(new ChannelItem
+ {
+ ChannelId = channelId,
+ Name = c.Name,
+ Type = c.Type,
+ Group = c.Group,
+ IsReadOnly = c.IsReadOnly,
+ CanPost = canPost,
+ CanManage = canManage,
+ CreatedAt = c.CreatedAt
+ });
+ }
+
+ return items;
+ }
+
protected override void OnClose(CloseEventArgs e)
{
ConnectedClientService.Unregister(ID);
RtcChannelPresenceService.RemoveSession(ID);
- Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
+ Console.WriteLine($"WS closed: session={ID}, code={e.Code}");
base.OnClose(e);
}
protected override void OnError(ErrorEventArgs e)
{
- Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
+ Console.WriteLine($"WS error: session={ID}, message={e.Message}");
base.OnError(e);
}
- private async void HandleAuthenticate(WsControlMessage control)
+ private void RegisterOrUpdateClientKeySync(string username, string publicKey) =>
+ Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
+
+ private List GetChannelsSync() =>
+ Task.Run(async () => await Db!.Select("channels")).GetAwaiter().GetResult().ToList();
+
+ private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) =>
+ Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)).GetAwaiter().GetResult();
+
+ private List GetChannelMessagesSync() =>
+ Task.Run(async () => await Db!.Select("channel_messages")).GetAwaiter().GetResult().ToList();
+
+ private ChannelMessages? GetChannelMessageByIdSync(string messageId) =>
+ GetChannelMessagesSync().FirstOrDefault(m => GetRecordId(m.Id) == messageId);
+
+ private ChannelMessages CreateChannelMessageSync(ChannelMessages message) =>
+ Task.Run(async () => await Db!.Create("channel_messages", message)).GetAwaiter().GetResult();
+
+ private void UpdateChannelMessageSync(ChannelMessages message) =>
+ Task.Run(async () => await Db!.Merge(message)).GetAwaiter().GetResult();
+
+ private void CreateChannelMessageEditSync(ChannelMessageEdits edit) =>
+ Task.Run(async () => await Db!.Create("channel_message_edits", edit)).GetAwaiter().GetResult();
+
+ private List GetChannelMessageEditsSync(string messageId)
{
- if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.Token))
- {
- Console.WriteLine("Invalid Authenticate payload.");
- return;
- }
-
- using var core = new HttpClient { BaseAddress = new Uri("http://192.168.1.85:1337") };
- core.DefaultRequestHeaders.Accept.Clear();
- core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
- core.DefaultRequestHeaders.Add("User-Agent", "RelayServer");
-
- var response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify
- {
- Username = control.Username,
- Token = control.Token
- });
-
- Console.WriteLine($"Auth response for {control.Username}: {await response.Content.ReadAsStringAsync()}");
-
- var result = new WsEventMessage { Event = WsEvent.Authenticated, Detail = control.Username };
- Send(JsonSerializer.Serialize(result));
+ var all = Task.Run(async () => await Db!.Select("channel_message_edits"))
+ .GetAwaiter().GetResult().ToList();
+ return all.Where(e => e.MessageId == messageId).ToList();
}
- private void HandleRegisterKey(WsControlMessage control)
- {
- if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.PublicKey))
- {
- Console.WriteLine("Invalid RegisterKey payload.");
- return;
- }
-
- if (ClientKeyService is null)
- {
- Console.WriteLine("ClientKeyService is not initialized.");
- return;
- }
-
- RegisterOrUpdateClientKeySync(control.Username, control.PublicKey);
- ConnectedClientService.Register(ID, control.Username);
-
- var response = new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = control.Username };
- Send(JsonSerializer.Serialize(response));
- }
-
- private void HandleGetServerKey()
- {
- if (string.IsNullOrWhiteSpace(ServerPublicKey))
- {
- Console.WriteLine("Server public key is not initialized.");
- return;
- }
-
- var payload = new ServerPublicKeyMessage
- {
- Type = SignalType.ServerPublicKey,
- PublicKey = ServerPublicKey
- };
-
- Send(JsonSerializer.Serialize(payload));
- }
-
- private void HandleGetChannels()
- {
- if (Db is null)
- {
- Console.WriteLine("Db is not initialized.");
- return;
- }
-
- var channels = GetChannelsSync()
- .OrderBy(c => c.CreatedAt)
- .Select(c => new ChannelItem
- {
- ChannelId = GetRecordId(c.Id),
- Name = c.Name,
- CreatedAt = c.CreatedAt
- })
- .ToList();
-
- var payload = new SocketChannelList
- {
- Type = SignalType.ChannelList,
- Channels = channels
- };
-
- Send(JsonSerializer.Serialize(payload));
- }
-
- private void HandleGetHistory(WsControlMessage control)
- {
- if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.ChannelId))
- {
- Console.WriteLine("Invalid GetHistory payload.");
- return;
- }
-
- if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
- {
- Console.WriteLine("History dependencies are not initialized.");
- return;
- }
-
- var targetClient = GetClientPublicKeyByUsernameSync(control.Username);
-
- if (targetClient is null)
- {
- Console.WriteLine($"No public key found for history request user {control.Username}");
- return;
- }
-
- var channelMessages = GetChannelMessagesSync()
- .Where(m => m.ChannelId == control.ChannelId)
- .OrderBy(m => m.CreatedAt)
- .ToList();
-
- Console.WriteLine($"Sending {channelMessages.Count} history messages to {control.Username}");
-
- foreach (var dbMessage in channelMessages)
- {
- string plainText;
-
- try
- {
- plainText = ChannelCryptoService.Decrypt(
- dbMessage.CipherText,
- dbMessage.Nonce,
- dbMessage.Tag,
- ChannelDbKey
- );
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}");
- continue;
- }
-
- var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey);
-
- var outbound = new SocketEncryptedMessage
- {
- Type = SignalType.EncryptedChat,
- SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
- RecipientUsername = control.Username,
- ChannelId = control.ChannelId,
- CipherText = encrypted.CipherText,
- Nonce = encrypted.Nonce,
- Tag = encrypted.Tag,
- EncryptedKey = encrypted.EncryptedKey
- };
-
- Send(JsonSerializer.Serialize(outbound));
- }
- }
-
- private void HandleRtcJoinChannel(WsControlMessage control)
- {
- if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.ChannelId))
- {
- Console.WriteLine("Invalid RtcJoin payload.");
- return;
- }
-
- RtcChannelPresenceService.SetUser(ID, control.Username);
- RtcChannelPresenceService.JoinChannel(ID, control.ChannelId);
-
- Console.WriteLine($"RTC presence joined: session={ID}, user={control.Username}, channel={control.ChannelId}");
- }
-
- private void HandleRtcLeaveChannel(WsControlMessage control)
- {
- if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.ChannelId))
- {
- Console.WriteLine("Invalid RtcLeave payload.");
- return;
- }
-
- if (RtcChannelPresenceService.IsInChannel(ID, control.ChannelId))
- RtcChannelPresenceService.LeaveChannel(ID);
-
- Console.WriteLine($"RTC presence left: session={ID}, user={control.Username}, channel={control.ChannelId}");
- }
-
- private void HandleEncryptedChatMessage(string msg)
- {
- SocketEncryptedMessage? clientPayload;
-
- try
- {
- clientPayload = JsonSerializer.Deserialize(msg);
- }
- catch
- {
- Console.WriteLine("Failed to parse encrypted client payload.");
- return;
- }
-
- if (clientPayload is null || clientPayload.Type != SignalType.ClientEncryptedChat)
- return;
-
- if (!EnsureCoreReady() || !EnsureCryptoReady())
- return;
-
- string plainText;
-
- try
- {
- plainText = E2EeHelper.DecryptForRecipient(
- new EncryptedPayload
- {
- CipherText = clientPayload.CipherText,
- Nonce = clientPayload.Nonce,
- Tag = clientPayload.Tag,
- EncryptedKey = clientPayload.EncryptedKey
- },
- ServerPrivateKey
- );
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to decrypt client payload: {ex.Message}");
- return;
- }
-
- Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
-
- try
- {
- var dbEncrypted = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey);
-
- var savedMessage = CreateChannelMessageSync(new ChannelMessages
- {
- ChannelId = clientPayload.ChannelId,
- SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
- CipherText = dbEncrypted.cipherText,
- Nonce = dbEncrypted.nonce,
- Tag = dbEncrypted.tag,
- CreatedAt = DateTime.UtcNow
- });
-
- Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to save live message to DB: {ex.Message}");
- return;
- }
-
- var members = GetServerMembersSync();
-
- foreach (var member in members)
- {
- var username = ExtractUsernameFromUserId(member.UserId);
- var sessionIds = ConnectedClientService.GetSessionsForUser(username);
-
- if (!sessionIds.Any())
- continue;
-
- // Preserve the exact casing the client registered with
- var properUsername = sessionIds
- .Select(ConnectedClientService.GetUsernameForSession)
- .FirstOrDefault(u => u is not null) ?? username;
-
- var clientKey = GetClientPublicKeyByUsernameSync(properUsername);
-
- if (clientKey is null)
- continue;
-
- var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
-
- Console.WriteLine($"Routing message from {clientPayload.SenderUsername} to {properUsername}");
-
- var outbound = new SocketEncryptedMessage
- {
- Type = SignalType.EncryptedChat,
- SenderUsername = clientPayload.SenderUsername,
- RecipientUsername = properUsername,
- ChannelId = clientPayload.ChannelId,
- CipherText = encrypted.CipherText,
- Nonce = encrypted.Nonce,
- Tag = encrypted.Tag,
- EncryptedKey = encrypted.EncryptedKey
- };
-
- var json = JsonSerializer.Serialize(outbound);
-
- foreach (var sessionId in sessionIds)
- Sessions.SendTo(json, sessionId);
- }
- }
-
- private void HandleEncryptedRtcSignal(string msg)
- {
- Console.WriteLine("RTC SIGNAL HIT");
- SocketRtcSignalMessage? clientPayload;
-
- try
- {
- clientPayload = JsonSerializer.Deserialize(msg);
- }
- catch
- {
- Console.WriteLine("Failed to parse encrypted RTC signal payload.");
- return;
- }
-
- if (clientPayload is null || clientPayload.Type != SignalType.EncryptedSignal)
- return;
-
- if (string.IsNullOrWhiteSpace(clientPayload.ChannelId))
- {
- Console.WriteLine("Encrypted RTC signal missing channel id.");
- return;
- }
-
- string plainText;
-
- try
- {
- plainText = E2EeHelper.DecryptForRecipient(
- new EncryptedPayload
- {
- CipherText = clientPayload.CipherText,
- Nonce = clientPayload.Nonce,
- Tag = clientPayload.Tag,
- EncryptedKey = clientPayload.EncryptedKey
- },
- ServerPrivateKey
- );
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to decrypt RTC signal: {ex.Message}");
- return;
- }
-
- var sessionIds = RtcChannelPresenceService.GetSessionsInChannel(clientPayload.ChannelId);
-
- foreach (var sessionId in sessionIds)
- {
- if (sessionId == ID)
- continue;
-
- var username = RtcChannelPresenceService.GetUsernameForSession(sessionId);
- if (string.IsNullOrWhiteSpace(username))
- continue;
-
- var clientKey = GetClientPublicKeyByUsernameSync(username);
- if (clientKey is null)
- continue;
-
- var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
-
- var outbound = new SocketRtcSignalMessage
- {
- Type = SignalType.EncryptedSignal,
- SenderUsername = clientPayload.SenderUsername,
- ChannelId = clientPayload.ChannelId,
- CipherText = encrypted.CipherText,
- Nonce = encrypted.Nonce,
- Tag = encrypted.Tag,
- EncryptedKey = encrypted.EncryptedKey
- };
-
- Sessions.SendTo(JsonSerializer.Serialize(outbound), sessionId);
- }
-
- Console.WriteLine($"Forwarded encrypted RTC signal from {clientPayload.SenderUsername} to channel {clientPayload.ChannelId}");
- }
+ private List GetServerMembersSync() =>
+ Task.Run(async () => await Db!.Select("server_members")).GetAwaiter().GetResult().ToList();
private static string ExtractUsernameFromUserId(string senderUserId)
{
- if (string.IsNullOrWhiteSpace(senderUserId))
- return "Unknown";
-
+ if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
var parts = senderUserId.Split(':', 2);
return parts.Length == 2 ? parts[1] : senderUserId;
}
private static string GetRecordId(object? id)
{
- if (id is null)
- return string.Empty;
-
+ if (id is null) return string.Empty;
var json = JsonSerializer.Serialize(id);
-
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
-
- var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
- var table = root.GetProperty("Table").GetString() ?? string.Empty;
-
- return $"{table}:{recordId}";
- }
-
- private void RegisterOrUpdateClientKeySync(string username, string publicKey)
- {
- Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
- .GetAwaiter()
- .GetResult();
- }
-
- private List GetChannelsSync()
- {
- return Task.Run(async () => await Db!.Select("channels"))
- .GetAwaiter()
- .GetResult()
- .ToList();
- }
-
- private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
- {
- return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
- .GetAwaiter()
- .GetResult();
- }
-
- private List GetChannelMessagesSync()
- {
- return Task.Run(async () => await Db!.Select("channel_messages"))
- .GetAwaiter()
- .GetResult()
- .ToList();
- }
-
- private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
- {
- return Task.Run(async () => await Db!.Create("channel_messages", message))
- .GetAwaiter()
- .GetResult();
- }
-
- private List GetServerMembersSync()
- {
- return Task.Run(async () => await Db!.Select("server_members"))
- .GetAwaiter()
- .GetResult()
- .ToList();
+ return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
}
private bool EnsureCoreReady()
{
- if (ClientKeyService is null || Db is null)
- {
- Console.WriteLine("Core services not initialized.");
- return false;
- }
-
+ if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
return true;
}
private bool EnsureCryptoReady()
{
- if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
+ if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
{
- Console.WriteLine("Crypto keys not initialized.");
+ Console.WriteLine("Crypto keys null.");
return false;
}
-
- if (ChannelCryptoService is null)
- {
- Console.WriteLine("ChannelCryptoService is not initialized.");
- return false;
- }
-
return true;
}
}
diff --git a/RelayServer/Services/Core/ServerBootstrapService.cs b/RelayServer/Services/Core/ServerBootstrapService.cs
index d8b3641..84ed33c 100644
--- a/RelayServer/Services/Core/ServerBootstrapService.cs
+++ b/RelayServer/Services/Core/ServerBootstrapService.cs
@@ -2,16 +2,13 @@ using System.Text.Json;
using RelayServer.Models;
using RelayServer.Services.Chat;
using RelayServer.Services.Crypto;
+using RelayShared.Services;
using SurrealDb.Net;
namespace RelayServer.Services.Core;
public sealed class ServerBootstrapService
{
- // TODO: Make channels dynamically addable
- // TODO: Add logic for channel types (ENUM)
- // TODO: Add logic for channel groups for future UI use
-
private readonly SurrealDbClient _db;
private readonly CoreClientService _coreClient;
private readonly ChannelCryptoService _cryptoService;
@@ -29,8 +26,8 @@ public sealed class ServerBootstrapService
public async Task InitializeAsync()
{
var keeper = await _coreClient.GetUserByUsernameAsync("Keeper317");
- var kira = await _coreClient.GetUserByUsernameAsync("Ru_Kira");
- var test = await _coreClient.GetUserByUsernameAsync("Test");
+ var kira = await _coreClient.GetUserByUsernameAsync("Ru_Kira");
+ var test = await _coreClient.GetUserByUsernameAsync("Test");
if (keeper is null || kira is null || test is null)
throw new InvalidOperationException("One or more required users do not exist in RelayCore.");
@@ -38,9 +35,7 @@ public sealed class ServerBootstrapService
if (!keeper.Licensed || !kira.Licensed || !test.Licensed)
throw new InvalidOperationException("One or more required users are not licensed.");
- Console.WriteLine($"Core verified user: {keeper.Username}");
- Console.WriteLine($"Core verified user: {kira.Username}");
- Console.WriteLine($"Core verified user: {test.Username}");
+ Console.WriteLine($"Core verified: {keeper.Username}, {kira.Username}, {test.Username}");
var server = await GetServerByNameAsync("Test Server");
@@ -52,44 +47,61 @@ public sealed class ServerBootstrapService
OwnerUserId = keeper.Id,
CreatedAt = DateTime.UtcNow
});
-
- Console.WriteLine($"Server created: {ToJsonString(server)}");
+ Console.WriteLine($"Server created: {ToJson(server)}");
}
else
{
- Console.WriteLine($"Server already exists: {ToJsonString(server)}");
+ Console.WriteLine($"Server already exists: {server.Name}");
}
- await EnsureServerMemberAsync(keeper.Id, true);
- await EnsureServerMemberAsync(kira.Id, false);
- await EnsureServerMemberAsync(test.Id, false);
-
+ await EnsureServerMemberAsync(keeper.Id, isOwner: true);
+ await EnsureServerMemberAsync(kira.Id, isOwner: false);
+ await EnsureServerMemberAsync(test.Id, isOwner: false);
Console.WriteLine("Server members ensured.");
- var channel = await EnsureChannelAsync("general", DateTime.UtcNow);
- var channel2 = await EnsureChannelAsync("files", DateTime.UtcNow.Subtract(new TimeSpan(0, 4, 0, 0)));
- var channel3 = await EnsureChannelAsync("welcome", DateTime.UtcNow.Subtract(new TimeSpan(1, 4, 4, 4)));
- var channel4 = await EnsureChannelAsync("voice-general", DateTime.UtcNow.Subtract(new TimeSpan(0, 2, 0, 0)));
+ var tBase = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
- Console.WriteLine($"Resolved channelId: {GetRecordId(channel.Id)}");
- Console.WriteLine($"Resolved channelId: {GetRecordId(channel2.Id)}");
- Console.WriteLine($"Resolved channelId: {GetRecordId(channel3.Id)}");
- Console.WriteLine($"Resolved channelId: {GetRecordId(channel4.Id)}");
+ var chWelcome = await EnsureChannelAsync("welcome", ChannelType.Text, group: "General", isReadOnly: true, createdAt: tBase);
+ var chGeneral = await EnsureChannelAsync("general", ChannelType.Text, group: "General", isReadOnly: false, createdAt: tBase.AddHours(1));
+ var chFiles = await EnsureChannelAsync("files", ChannelType.File, group: "General", isReadOnly: true, createdAt: tBase.AddHours(2));
+ var chVoice = await EnsureChannelAsync("voice-general", ChannelType.Voice, group: "General", isReadOnly: false, createdAt: tBase.AddHours(3));
+
+ Console.WriteLine($"Channels: {GetRecordId(chWelcome.Id)} | {GetRecordId(chGeneral.Id)} | {GetRecordId(chFiles.Id)} | {GetRecordId(chVoice.Id)}");
+
+ await EnsureFileChannelLinkAsync(chGeneral, GetRecordId(chFiles.Id));
+
+ var adminRole = await EnsureRoleAsync("Admin", PermissionFlags.Administrator, priority: 0);
+ var modRole = await EnsureRoleAsync("Moderator", PermissionFlags.ReadMessages | PermissionFlags.SendMessages | PermissionFlags.ManageMessages, priority: 1);
+ var memberRole = await EnsureRoleAsync("Member", PermissionFlags.ReadMessages | PermissionFlags.SendMessages, priority: 2);
+
+ Console.WriteLine($"Roles ensured: Admin={GetRecordId(adminRole.Id)}, Mod={GetRecordId(modRole.Id)}, Member={GetRecordId(memberRole.Id)}");
+
+ await SetUserRoleAsync(keeper.Id, GetRecordId(adminRole.Id));
+ await SetUserRoleAsync(kira.Id, GetRecordId(modRole.Id));
+ await SetUserRoleAsync(test.Id, GetRecordId(memberRole.Id));
+ Console.WriteLine("User roles set.");
+
+ await EnsureChannelPermissionAsync(GetRecordId(chWelcome.Id), GetRecordId(memberRole.Id),
+ allow: PermissionFlags.ReadMessages, deny: PermissionFlags.SendMessages);
+ await EnsureChannelPermissionAsync(GetRecordId(chFiles.Id), GetRecordId(memberRole.Id),
+ allow: PermissionFlags.ReadMessages, deny: PermissionFlags.SendMessages);
+
+ Console.WriteLine("Channel permissions ensured.");
var existingKey = await GetLatestServerEncryptionKeyAsync();
if (existingKey is null)
{
- var keyBase64 = _cryptoService.GenerateKey();
+ var keyBase64 = _cryptoService.GenerateKey();
var serverKeys = E2EeHelper.GenerateRsaKeyPair();
existingKey = await _db.Create("server_encryption_keys", new ServerEncryptionKeys
{
- KeyBase64 = keyBase64,
- PublicKey = serverKeys.publicKey,
+ KeyBase64 = keyBase64,
+ PublicKey = serverKeys.publicKey,
PrivateKey = serverKeys.privateKey,
- CreatedAt = DateTime.UtcNow,
- UpdatedAt = DateTime.UtcNow
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
});
Console.WriteLine("Server encryption key created.");
@@ -104,92 +116,181 @@ public sealed class ServerBootstrapService
ChatSocketBehavior.ChannelDbKey = existingKey.KeyBase64;
}
- private static string ToJsonString(object? obj)
+ private async Task EnsureServerMemberAsync(string userId, bool isOwner)
{
- return JsonSerializer.Serialize(obj, new JsonSerializerOptions
+ var members = await _db.Select("server_members");
+ var existing = members.FirstOrDefault(m => m.UserId == userId);
+
+ if (existing is not null)
{
- WriteIndented = true,
- Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ if (existing.IsOwner != isOwner)
+ {
+ existing.IsOwner = isOwner;
+ await _db.Merge(existing);
+ Console.WriteLine($"Member IsOwner updated: {userId} → {isOwner}");
+ }
+ else
+ {
+ Console.WriteLine($"Member already correct: {userId}");
+ }
+ return;
+ }
+
+ await _db.Create("server_members", new ServerMembers
+ {
+ UserId = userId,
+ JoinedAt = DateTime.UtcNow,
+ IsOwner = isOwner
});
+ Console.WriteLine($"Member created: {userId} (IsOwner={isOwner})");
}
- private static string GetRecordId(object? id)
+ private async Task EnsureChannelAsync(
+ string name, ChannelType type, string group, bool isReadOnly, DateTime createdAt)
{
- if (id is null)
- return string.Empty;
+ var channels = await _db.Select("channels");
+ var existing = channels.FirstOrDefault(c => c.Name == name);
- var json = JsonSerializer.Serialize(id);
+ if (existing is not null)
+ {
+ bool dirty = existing.Type != type || existing.Group != group || existing.IsReadOnly != isReadOnly;
+ if (dirty)
+ {
+ existing.Type = type;
+ existing.Group = group;
+ existing.IsReadOnly = isReadOnly;
+ await _db.Merge(existing);
+ Console.WriteLine($"Channel updated: {name}");
+ }
+ else
+ {
+ Console.WriteLine($"Channel already correct: {name}");
+ }
+ return existing;
+ }
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
+ var channel = await _db.Create("channels", new Channels
+ {
+ Name = name,
+ Type = type,
+ Group = group,
+ IsReadOnly = isReadOnly,
+ CreatedAt = createdAt
+ });
- var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
- var table = root.GetProperty("Table").GetString() ?? string.Empty;
-
- return $"{table}:{recordId}";
+ Console.WriteLine($"Channel created: {name} ({type})");
+ return channel;
}
-
+
+ private async Task EnsureFileChannelLinkAsync(Channels channel, string fileChannelId)
+ {
+ if (channel.LinkedFileChannelId == fileChannelId)
+ {
+ Console.WriteLine($"File link already correct: {channel.Name} → {fileChannelId}");
+ return;
+ }
+
+ channel.LinkedFileChannelId = fileChannelId;
+ await _db.Merge(channel);
+ Console.WriteLine($"File link set: {channel.Name} → {fileChannelId}");
+ }
+
+ private async Task EnsureRoleAsync(string name, PermissionFlags permissions, int priority)
+ {
+ var roles = await _db.Select("roles");
+ var existing = roles.FirstOrDefault(r => r.Name == name);
+
+ if (existing is not null)
+ {
+ Console.WriteLine($"Role already exists: {name}");
+ return existing;
+ }
+
+ var role = await _db.Create("roles", new Roles
+ {
+ Name = name,
+ Permissions = permissions,
+ Priority = priority,
+ CreatedAt = DateTime.UtcNow
+ });
+ Console.WriteLine($"Role created: {name}");
+ return role;
+ }
+
+ private async Task SetUserRoleAsync(string userId, string roleId)
+ {
+ var userRoles = await _db.Select("user_roles");
+ var existing = userRoles
+ .Where(ur => string.Equals(ur.UserId, userId, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ bool alreadyCorrect = existing.Count == 1 && existing[0].RoleId == roleId;
+ if (alreadyCorrect)
+ {
+ Console.WriteLine($"UserRole already correct: {userId} → {roleId}");
+ return;
+ }
+
+ foreach (var stale in existing)
+ {
+ if (stale.Id is not null)
+ await _db.Delete(stale.Id);
+ }
+
+ await _db.Create("user_roles", new UserRoles
+ {
+ UserId = userId,
+ RoleId = roleId,
+ AssignedAt = DateTime.UtcNow
+ });
+ Console.WriteLine($"UserRole set: {userId} → {roleId}");
+ }
+
+ private async Task EnsureChannelPermissionAsync(
+ string channelId, string roleId, PermissionFlags allow, PermissionFlags deny)
+ {
+ var perms = await _db.Select("channel_permissions");
+ if (perms.Any(cp => cp.ChannelId == channelId && cp.RoleId == roleId))
+ {
+ Console.WriteLine($"ChannelPermission already exists: {channelId} → {roleId}");
+ return;
+ }
+
+ await _db.Create("channel_permissions", new ChannelPermissions
+ {
+ ChannelId = channelId,
+ RoleId = roleId,
+ Allow = allow,
+ Deny = deny
+ });
+ Console.WriteLine($"ChannelPermission created: {channelId} → {roleId} | allow={allow}, deny={deny}");
+ }
+
private async Task GetServerByNameAsync(string name)
{
var servers = await _db.Select("servers");
return servers.FirstOrDefault(x => x.Name == name);
}
- private async Task GetServerMemberByUserIdAsync(string userId)
- {
- var members = await _db.Select("server_members");
- return members.FirstOrDefault(x => x.UserId == userId);
- }
-
- private async Task GetChannelByNameAsync(string name)
- {
- var channels = await _db.Select("channels");
- return channels.FirstOrDefault(x => x.Name == name);
- }
-
private async Task GetLatestServerEncryptionKeyAsync()
{
var keys = await _db.Select("server_encryption_keys");
- return keys
- .OrderByDescending(x => x.CreatedAt)
- .FirstOrDefault();
+ return keys.OrderByDescending(x => x.CreatedAt).FirstOrDefault();
}
-
- private async Task EnsureServerMemberAsync(string userId, bool isOwner)
+
+ private static string GetRecordId(object? id)
{
- var existing = await GetServerMemberByUserIdAsync(userId);
- if (existing is not null)
- {
- Console.WriteLine($"Server member already exists for {userId}");
- return;
- }
-
- await _db.Create("server_members", new ServerMembers
- {
- UserId = userId,
- JoinedAt = DateTime.UtcNow,
- IsOwner = isOwner
- });
-
- Console.WriteLine($"Server member created for {userId}");
+ if (id is null) return string.Empty;
+ var json = JsonSerializer.Serialize(id);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
}
-
- private async Task EnsureChannelAsync(string name, DateTime createdAt)
- {
- var existing = await GetChannelByNameAsync(name);
- if (existing is not null)
- {
- Console.WriteLine($"Channel already exists: {name}");
- return existing;
- }
- var channel = await _db.Create("channels", new Channels
+ private static string ToJson(object? obj) =>
+ JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
- Name = name,
- CreatedAt = createdAt
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
-
- Console.WriteLine($"Channel created: {ToJsonString(channel)}");
- return channel;
- }
-}
\ No newline at end of file
+}
diff --git a/RelayServer/Services/Data/PermissionService.cs b/RelayServer/Services/Data/PermissionService.cs
new file mode 100644
index 0000000..cc7cddc
--- /dev/null
+++ b/RelayServer/Services/Data/PermissionService.cs
@@ -0,0 +1,160 @@
+using RelayServer.Models;
+using SurrealDb.Net;
+
+namespace RelayServer.Services.Data;
+
+public sealed class PermissionService
+{
+ private readonly SurrealDbClient _db;
+
+ public PermissionService(SurrealDbClient db)
+ {
+ _db = db;
+ }
+
+ public async Task CanSendMessagesAsync(string username, string channelId)
+ {
+ if (await IsOwnerOrAdminAsync(username))
+ return true;
+
+ if (await IsChannelReadOnlyAsync(channelId))
+ return false;
+
+ return await HasPermissionAsync(username, channelId, PermissionFlags.SendMessages);
+ }
+
+ public async Task CanManageChannelsAsync(string username) =>
+ await IsOwnerOrAdminAsync(username) ||
+ await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels);
+
+ public async Task CanManageMessagesAsync(string username, string channelId) =>
+ await IsOwnerOrAdminAsync(username) ||
+ await HasPermissionAsync(username, channelId, PermissionFlags.ManageMessages);
+
+ public async Task IsAdministratorAsync(string username) =>
+ await IsOwnerOrAdminAsync(username);
+
+ public async Task CanViewChannelAsync(string username, string channelId)
+ {
+ if (await IsOwnerOrAdminAsync(username)) return true;
+ return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.ViewChannel);
+ }
+
+ public async Task CanSpeakAsync(string username, string channelId)
+ {
+ if (await IsOwnerOrAdminAsync(username)) return true;
+ return !await IsDeniedByChannelAsync(username, channelId, PermissionFlags.Speak);
+ }
+
+ public async Task CanDeleteChannelAsync(string username) =>
+ await IsOwnerOrAdminAsync(username) ||
+ await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
+ await HasGlobalPermissionAsync(username, PermissionFlags.DeleteChannel);
+
+ public async Task CanEditChannelAsync(string username) =>
+ await IsOwnerOrAdminAsync(username) ||
+ await HasGlobalPermissionAsync(username, PermissionFlags.ManageChannels) ||
+ await HasGlobalPermissionAsync(username, PermissionFlags.EditChannel);
+
+ private async Task IsOwnerOrAdminAsync(string username)
+ {
+ if (await IsServerOwnerAsync(username))
+ return true;
+
+ var roles = await GetUserRolesAsync(username);
+ return roles.Any(r => r.Permissions.HasFlag(PermissionFlags.Administrator));
+ }
+
+ private async Task HasPermissionAsync(
+ string username, string channelId, PermissionFlags flag)
+ {
+ if (await IsOwnerOrAdminAsync(username))
+ return true;
+
+ var userRoles = await GetUserRolesAsync(username);
+ if (userRoles.Count == 0) return false;
+
+ var channelOverrides = await GetChannelPermissionsAsync(channelId);
+ var userRoleIds = new HashSet(userRoles.Select(r => GetRecordIdString(r.Id)));
+
+ foreach (var co in channelOverrides.Where(co => userRoleIds.Contains(co.RoleId)))
+ if (co.Deny.HasFlag(flag)) return false;
+
+ foreach (var co in channelOverrides.Where(co => userRoleIds.Contains(co.RoleId)))
+ if (co.Allow.HasFlag(flag)) return true;
+
+ return userRoles.Any(r => r.Permissions.HasFlag(flag));
+ }
+
+ private async Task HasGlobalPermissionAsync(string username, PermissionFlags flag)
+ {
+ var roles = await GetUserRolesAsync(username);
+ return roles.Any(r =>
+ r.Permissions.HasFlag(PermissionFlags.Administrator) ||
+ r.Permissions.HasFlag(flag));
+ }
+
+ private async Task IsDeniedByChannelAsync(string username, string channelId, PermissionFlags flag)
+ {
+ var userRoles = await GetUserRolesAsync(username);
+ if (userRoles.Count == 0) return false;
+
+ var channelOverrides = await GetChannelPermissionsAsync(channelId);
+ var userRoleIds = new HashSet(userRoles.Select(r => GetRecordIdString(r.Id)));
+
+ return channelOverrides
+ .Where(co => userRoleIds.Contains(co.RoleId))
+ .Any(co => co.Deny.HasFlag(flag));
+ }
+
+ private async Task IsServerOwnerAsync(string username)
+ {
+ var userId = $"users:{username.ToLower()}";
+ var members = await _db.Select("server_members");
+ return members.Any(m =>
+ string.Equals(m.UserId, userId, StringComparison.OrdinalIgnoreCase) &&
+ m.IsOwner);
+ }
+
+ private async Task> GetUserRolesAsync(string username)
+ {
+ var userId = $"users:{username.ToLower()}";
+
+ var userRoleLinks = await _db.Select("user_roles");
+ var userRoleIds = userRoleLinks
+ .Where(ur => string.Equals(ur.UserId, userId, StringComparison.OrdinalIgnoreCase))
+ .Select(ur => ur.RoleId)
+ .ToHashSet();
+
+ if (userRoleIds.Count == 0) return [];
+
+ var allRoles = await _db.Select("roles");
+ return allRoles
+ .Where(r => userRoleIds.Contains(GetRecordIdString(r.Id)))
+ .ToList();
+ }
+
+ private async Task> GetChannelPermissionsAsync(string channelId)
+ {
+ var all = await _db.Select("channel_permissions");
+ return all.Where(cp => cp.ChannelId == channelId).ToList();
+ }
+
+ private async Task IsChannelReadOnlyAsync(string channelId)
+ {
+ var channels = await _db.Select("channels");
+ var channel = channels.FirstOrDefault(c => GetRecordIdString(c.Id) == channelId);
+ return channel?.IsReadOnly ?? false;
+ }
+
+ private static string GetRecordIdString(object? id)
+ {
+ if (id is null) return string.Empty;
+ var json = System.Text.Json.JsonSerializer.Serialize(id);
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
+ var table = root.GetProperty("Table").GetString() ?? string.Empty;
+ return $"{table}:{recordId}";
+ }
+}
diff --git a/RelayShared/Services/ChannelTransmissions.cs b/RelayShared/Services/ChannelTransmissions.cs
index 99da406..8ad49a2 100644
--- a/RelayShared/Services/ChannelTransmissions.cs
+++ b/RelayShared/Services/ChannelTransmissions.cs
@@ -1,18 +1,19 @@
-namespace RelayShared.Services;
+namespace RelayShared.Services;
public sealed class ChannelItem
{
public string ChannelId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
-
public ChannelType Type { get; set; }
-
public string Group { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
+ public bool IsReadOnly { get; set; }
+ public bool CanPost { get; set; }
+ public bool CanManage { get; set; }
}
public sealed class SocketChannelList
{
public SignalType Type { get; set; } = SignalType.ChannelList;
public List Channels { get; set; } = [];
-}
\ No newline at end of file
+}
diff --git a/RelayShared/Services/ChatMessageContent.cs b/RelayShared/Services/ChatMessageContent.cs
new file mode 100644
index 0000000..1d0e3b7
--- /dev/null
+++ b/RelayShared/Services/ChatMessageContent.cs
@@ -0,0 +1,14 @@
+namespace RelayShared.Services;
+
+public sealed class ChatMessageContent
+{
+ public string Text { get; set; } = string.Empty;
+
+ public string? ReplyToId { get; set; }
+ public string? ReplyToSenderUsername { get; set; }
+ public string? ReplyPreview { get; set; }
+ public List? Mentions { get; set; }
+ public string? AttachmentBase64 { get; set; }
+ public string? AttachmentMimeType { get; set; }
+ public string? AttachmentFileName { get; set; }
+}
diff --git a/RelayShared/Services/SocketTransmissions.cs b/RelayShared/Services/SocketTransmissions.cs
index ccf82df..a0473af 100644
--- a/RelayShared/Services/SocketTransmissions.cs
+++ b/RelayShared/Services/SocketTransmissions.cs
@@ -1,4 +1,4 @@
-namespace RelayShared.Services;
+namespace RelayShared.Services;
//TODO: review name of file, potentially rename for Encryption services rather than sockets
@@ -16,6 +16,8 @@ public sealed class SocketRtcSignalMessage
public sealed class SocketEncryptedMessage
{
public SignalType Type { get; set; } = SignalType.EncryptedChat;
+ public string MessageId { get; set; } = string.Empty;
+
public string SenderUsername { get; set; } = string.Empty;
public string RecipientUsername { get; set; } = string.Empty;
public string ChannelId { get; set; } = string.Empty;
@@ -23,6 +25,38 @@ public sealed class SocketEncryptedMessage
public string Nonce { get; set; } = string.Empty;
public string Tag { get; set; } = string.Empty;
public string EncryptedKey { get; set; } = string.Empty;
+ public bool IsEdited { get; set; }
+ public bool IsDeleted { get; set; }
+}
+
+public sealed class SocketMessageDeletedEvent
+{
+ public SignalType Type { get; set; } = SignalType.MessageDeleted;
+ public string MessageId { get; set; } = string.Empty;
+ public string ChannelId { get; set; } = string.Empty;
+}
+
+public sealed class SocketTypingEvent
+{
+ public SignalType Type { get; set; } = SignalType.TypingIndicator;
+ public string Username { get; set; } = string.Empty;
+ public string ChannelId { get; set; } = string.Empty;
+}
+
+public sealed class SocketEditHistoryEntry
+{
+ public string CipherText { get; set; } = string.Empty;
+ public string Nonce { get; set; } = string.Empty;
+ public string Tag { get; set; } = string.Empty;
+ public string EncryptedKey { get; set; } = string.Empty;
+ public DateTime EditedAt { get; set; }
+}
+
+public sealed class SocketEditHistoryResponse
+{
+ public SignalType Type { get; set; } = SignalType.EditHistory;
+ public string MessageId { get; set; } = string.Empty;
+ public List Entries { get; set; } = [];
}
public sealed class ServerPublicKeyMessage
@@ -44,5 +78,11 @@ public enum SignalType
ServerPublicKey,
EncryptedSignal,
EncryptedChat,
- ClientEncryptedChat
-}
\ No newline at end of file
+ ClientEncryptedChat,
+ ClientEditMessage,
+ ClientDeleteMessage,
+ MessageEdited,
+ MessageDeleted,
+ TypingIndicator,
+ EditHistory
+}
diff --git a/RelayShared/Services/WsControlMessage.cs b/RelayShared/Services/WsControlMessage.cs
index dea2ba6..992e7d8 100644
--- a/RelayShared/Services/WsControlMessage.cs
+++ b/RelayShared/Services/WsControlMessage.cs
@@ -1,4 +1,4 @@
-namespace RelayShared.Services;
+namespace RelayShared.Services;
public enum WsAction
{
@@ -8,7 +8,11 @@ public enum WsAction
GetChannels,
GetHistory,
RtcJoin,
- RtcLeave
+ RtcLeave,
+ SendTyping,
+ GetEditHistory,
+ CreateChannel,
+ DeleteChannel
}
public enum WsEvent
@@ -23,8 +27,12 @@ public sealed class WsControlMessage
public WsAction Action { get; set; }
public string? Username { get; set; }
public string? Token { get; set; }
- public string? ChannelId { get; set; }
public string? PublicKey { get; set; }
+ public string? ChannelId { get; set; }
+ public string? MessageId { get; set; }
+ public string? ChannelName { get; set; }
+ public int ChannelType { get; set; } // cast to ChannelType enum
+ public string? ChannelGroup { get; set; }
}
public sealed class WsEventMessage