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

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

1134 lines
40 KiB
C#

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;
namespace RelayClient;
public partial class MainPage : ContentPage
{
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<string, List<ChatMessage>> _messagesByChannel = new();
private readonly List<ChannelItem> _channels = [];
private readonly Dictionary<string, ChatMessage> _messagesById = new();
private readonly Dictionary<string, Border> _messageBubbles = new();
private ChatMessage? _replyingToMessage;
private string? _editingMessageId;
private string? _pendingAttachmentBase64;
private string? _pendingAttachmentMimeType;
private string? _pendingAttachmentFileName;
private CancellationTokenSource? _typingDebounce;
private readonly Dictionary<string, CancellationTokenSource> _typingClearTimers = new();
private bool _channelsInitialized;
private readonly Dictionary<string, int> _mentionCounts = new();
private static readonly Regex MentionExtract =
new(@"@(\w+)", RegexOptions.Compiled);
public MainPage(string username)
{
InitializeComponent();
_username = username;
UserLabel.Text = $"Logged in as: {_username}";
if (!KeyStorage.HasKeys(_username))
{
var keys = E2EeHelper.GenerateRsaKeyPair();
KeyStorage.SavePrivateKey(_username, keys.privateKey);
KeyStorage.SavePublicKey(_username, keys.publicKey);
}
_ = ServerAPI.setupClient();
_socket = new RelaySocketClient(_username);
_rtc = new RtcBridgeService(
_username, _socket, hybridWebView,
() => _currentChannelId,
SafeSendRawToWebView);
hybridWebView.SetInvokeJavaScriptTarget(_rtc);
_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));
_socket.Connect();
SetupEditorKeyHandler();
SetupFileDragAndDrop();
}
private void SetupFileDragAndDrop()
{
#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<Windows.Storage.StorageFile>().FirstOrDefault();
if (file is null) return;
await IngestPickedFileAsync(file.Path, file.Name);
}
catch (Exception ex)
{
Console.WriteLine($"Drop failed: {ex.Message}");
}
};
};
#endif
}
private void SetupEditorKeyHandler()
{
#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(_socket.ServerPublicKey) ||
string.IsNullOrWhiteSpace(_currentChannelId)) return;
if (_editingMessageId is not null)
{
var editContent = new ChatMessageContent { Text = text ?? string.Empty };
var editEncrypted = E2EeHelper.EncryptForRecipient(
JsonSerializer.Serialize(editContent), _socket.ServerPublicKey);
_socket.SendEditMessage(_editingMessageId, _currentChannelId!, editEncrypted);
CancelContext();
return;
}
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))
{
mentions.Add(_replyingToMessage.SenderUsername);
}
var content = new ChatMessageContent
{
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
};
var channelId = _currentChannelId!;
var serverPublicKey = _socket.ServerPublicKey!;
var attachmentName = _pendingAttachmentFileName;
CancelContext();
ClearPendingAttachment();
MessageEntry.Text = string.Empty;
_ = 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<string> { "↩ 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<string> { "⚙ 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<string>
{
$"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)
{
_channels.Clear();
_channels.AddRange(channelList.Channels.OrderBy(c => c.CreatedAt));
if (!_channelsInitialized)
{
_channelsInitialized = true;
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.Equals(_username, StringComparison.OrdinalIgnoreCase)) return;
ChatMessage message;
if (payload.IsDeleted)
{
message = new ChatMessage
{
MessageId = payload.MessageId,
SenderUsername = payload.SenderUsername,
Timestamp = DateTime.MinValue,
IsDeleted = true
};
}
else
{
if (!TryDecryptAndParseContent(payload, out var content)) return;
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));
}
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<ChatMessageContent>(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<ChatMessageContent>(decryptedText) ?? new ChatMessageContent { Text = decryptedText }; }
catch { content = new ChatMessageContent { Text = decryptedText }; }
return true;
}
protected override void OnDisappearing()
{
_socket.Disconnect();
base.OnDisappearing();
}
private void RenderChannelList()
{
SidebarList.Children.Clear();
var grouped = _channels
.OrderBy(c => c.CreatedAt)
.GroupBy(c => string.IsNullOrWhiteSpace(c.Group) ? "Channels" : c.Group);
foreach (var group in grouped)
{
SidebarList.Children.Add(new Label
{
Text = group.Key.ToUpper(),
FontSize = 10,
FontAttributes = FontAttributes.Bold,
TextColor = Colors.Gray,
Margin = new Thickness(0, 6, 0, 2)
});
foreach (var channel in group)
{
bool isSelected = channel.ChannelId == _currentChannelId;
var prefix = channel.Type switch
{
ChannelType.Voice => "🔊",
ChannelType.Forum => "📋",
ChannelType.Stage => "🎤",
ChannelType.File => "📁",
_ => "#"
};
var lockSuffix = channel.IsReadOnly ? " 🔒" : string.Empty;
_mentionCounts.TryGetValue(channel.ChannelId, out var mentionCount);
var pingSuffix = (mentionCount > 0 && !isSelected) ? $" 🔔{mentionCount}" : string.Empty;
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);
}
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 m in messages.OrderBy(m => m.Timestamp))
RenderSingleMessage(m);
}
private void SwapView_OnClicked(object? sender, EventArgs e) { }
private async void RenderSingleMessage(ChatMessage message)
{
bool isOwn = message.SenderUsername.Equals(_username, StringComparison.OrdinalIgnoreCase);
bool mentionsMe = MessageMentionsMe(message);
var bubble = new Border
{
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, animated: true);
}
private void RebuildBubbleContent(Border bubble, ChatMessage message)
{
bubble.Content = BuildBubbleContent(message);
}
private View BuildBubbleContent(ChatMessage message)
{
if (message.IsDeleted)
{
return new Label
{
Text = "🗑 This message was deleted.",
FontAttributes = FontAttributes.Italic,
TextColor = Colors.Gray,
FontSize = 13
};
}
var stack = new VerticalStackLayout { Spacing = 4 };
if (message.ReplyToId is not null && message.ReplyPreview is not null)
{
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<View>())
WireJumpLinks(child);
}
else if (view is Border border && border.Content is View borderContent)
{
WireJumpLinks(borderContent);
}
}
private void ScrollToMessage(string messageId)
{
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)
{
if (e.Message == "rtc_page_ready")
{
await _rtc.PushRtcContextToJsAsync();
return;
}
SafeSendRawToWebView($"JS → C#: {e.Message}");
}
private void SafeSendRawToWebView(string message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
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<string>? 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;
}
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(RtcDescription))]
[JsonSerializable(typeof(List<RtcSignalMessage>))]
[JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List<IceCandidate>))]
[JsonSerializable(typeof(string))]
internal partial class HybridJSType : JsonSerializerContext { }
}