1327 lines
53 KiB
C#
1327 lines
53 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;
|
|
|
|
/// <summary>
|
|
/// The chat shell. Owns:
|
|
/// - RelaySocketClient (the WebSocket to the server)
|
|
/// - RtcBridgeService (the bridge between the WS and the WebView-hosted WebRTC JS)
|
|
/// - All in-memory message/channel state for the active session.
|
|
///
|
|
/// Wire-up of socket events happens once in the constructor:
|
|
/// ChannelListReceived → HandleChannelList (rebuilds sidebar; auto-selects #welcome on first load only)
|
|
/// EncryptedChatReceived → HandleEncryptedChat (decrypt, append to channel buffer, render bubble)
|
|
/// MessageEdited → HandleMessageEdited (decrypt new text, mutate ChatMessage, rebuild bubble in-place)
|
|
/// MessageDeleted → HandleMessageDeleted (mark IsDeleted, rebuild bubble to tombstone)
|
|
/// TypingReceived → HandleTyping (show "X is typing…", auto-clear after 3s)
|
|
/// EditHistoryReceived → HandleEditHistory (decrypt every version, show in DisplayAlert)
|
|
/// EncryptedRtcSignalReceived → RtcBridgeService.HandleIncomingRtcSignalAsync
|
|
///
|
|
/// State dictionaries:
|
|
/// _messagesByChannel: channelId → message list (per-channel scrollback)
|
|
/// _messagesById : messageId → ChatMessage (O(1) lookup for edit/delete handlers)
|
|
/// _messageBubbles : messageId → MAUI Border (O(1) in-place bubble update on edit/delete)
|
|
/// _mentionCounts : channelId → unread @mention count (sidebar 🔔 badge)
|
|
/// _typingClearTimers: username → CTS (per-user typing-indicator timeout)
|
|
///
|
|
/// Bubble rendering is done by BuildBubbleContent (markdown body → MarkdownHelper.Render,
|
|
/// URL embeds → EmbedHelper.BuildEmbeds, attachments → EmbedHelper.BuildBase64ImageEmbed/BuildFileCard).
|
|
/// Right-click attaches platform-specific (WinUI RightTapped) context menus via
|
|
/// AttachMessageContextMenu / AttachChannelContextMenu.
|
|
/// </summary>
|
|
public partial class MainPage : ContentPage
|
|
{
|
|
/// <summary>Logged-in username. Static so RelaySocketClient can read it without a back-pointer.</summary>
|
|
public static string _username = string.Empty;
|
|
|
|
/// <summary>The WebSocket pipe to RelayServer.</summary>
|
|
private readonly RelaySocketClient _socket;
|
|
|
|
/// <summary>The C#↔JS bridge for the WebView WebRTC engine.</summary>
|
|
private readonly RtcBridgeService _rtc;
|
|
|
|
/// <summary>Core-issued auth token, set during sign-in by ClientSession.</summary>
|
|
public static string? _userToken;
|
|
|
|
/// <summary>"channels:abc" — the currently-viewed channel, or null if none.</summary>
|
|
private string? _currentChannelId;
|
|
|
|
/// <summary>Display name of the current channel (used for the header label).</summary>
|
|
private string? _currentChannelName;
|
|
|
|
/// <summary>Drives view switching (messages vs RTC) on channel select.</summary>
|
|
private ChannelType _currentChannelType = ChannelType.Text;
|
|
|
|
/// <summary>channelId → message list. Per-channel scrollback kept in memory for the session.</summary>
|
|
private readonly Dictionary<string, List<ChatMessage>> _messagesByChannel = new();
|
|
|
|
/// <summary>The full channel list as last delivered by the server (after Visibility filtering).</summary>
|
|
private readonly List<ChannelItem> _channels = [];
|
|
|
|
/// <summary>messageId → ChatMessage. O(1) lookup for edit/delete handlers.</summary>
|
|
private readonly Dictionary<string, ChatMessage> _messagesById = new();
|
|
|
|
/// <summary>messageId → MAUI Border (the bubble). O(1) in-place bubble rebuild on edit/delete.</summary>
|
|
private readonly Dictionary<string, Border> _messageBubbles = new();
|
|
|
|
/// <summary>When non-null, the next Send becomes a reply to this message.</summary>
|
|
private ChatMessage? _replyingToMessage;
|
|
|
|
/// <summary>When non-null, the next Send becomes an edit of this message.</summary>
|
|
private string? _editingMessageId;
|
|
|
|
/// <summary>Base64 of the staged file attachment. Cleared after Send or context cancel.</summary>
|
|
private string? _pendingAttachmentBase64;
|
|
private string? _pendingAttachmentMimeType;
|
|
private string? _pendingAttachmentFileName;
|
|
|
|
/// <summary>Cancels the previous "send typing event" if the user keeps typing. Debounce gate.</summary>
|
|
private CancellationTokenSource? _typingDebounce;
|
|
|
|
/// <summary>Per-username 3-second auto-clear timers for the typing indicator label.</summary>
|
|
private readonly Dictionary<string, CancellationTokenSource> _typingClearTimers = new();
|
|
|
|
/// <summary>
|
|
/// True after the first channel-list arrives. Used so subsequent channel-list broadcasts
|
|
/// (from create/delete) DON'T yank the user back to #welcome.
|
|
/// </summary>
|
|
private bool _channelsInitialized;
|
|
|
|
/// <summary>channelId → unread @mention count. Drives the 🔔N sidebar badge.</summary>
|
|
private readonly Dictionary<string, int> _mentionCounts = new();
|
|
|
|
/// <summary>Matches `@word` (where word is letters/digits/underscore). Used at send time to extract mentions.</summary>
|
|
private static readonly Regex MentionExtract =
|
|
new(@"@(\w+)", RegexOptions.Compiled);
|
|
|
|
/// <summary>
|
|
/// Generates a fresh RSA keypair if the user has none on disk, wires every socket event,
|
|
/// then opens the WebSocket. Connect() fires the auth/register-key/get-server-key/get-channels
|
|
/// handshake in order before any message can be sent.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows-only: wires AllowDrop/DragOver/Drop on the messages view via the WinUI native
|
|
/// UIElement. Drop pulls the first file out of the StorageItems payload and stages it via
|
|
/// IngestPickedFileAsync (same path as the 📎 button).
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows-only: replaces the WinUI TextBox's default Enter-handling with Enter=send,
|
|
/// Shift+Enter=newline. Has to set AcceptsReturn=false on the native TextBox and use
|
|
/// AddHandler(handledEventsToo: true) to intercept Enter before it bubbles back up
|
|
/// (the equivalent of WPF's PreviewKeyDown).
|
|
/// </summary>
|
|
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();
|
|
|
|
/// <summary>
|
|
/// Debounced typing-indicator fire. Resets a 400ms timer every keystroke; on timeout
|
|
/// (i.e. the user paused), sends SendTyping. Prevents spamming the server with one event
|
|
/// per character.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The main send path. Handles three modes:
|
|
/// - Edit mode (_editingMessageId set) → encrypts new text and fires SendEditMessage.
|
|
/// - New message → extracts @mentions, auto-mentions reply target, attaches any
|
|
/// staged file, encrypts the ChatMessageContent JSON, sends as ClientEncryptedChat.
|
|
///
|
|
/// CRITICAL: snapshots UI state to local variables BEFORE clearing the input, then runs
|
|
/// the encrypt+send off the UI thread via Task.Run. Attachments can be megabytes; doing
|
|
/// the encryption synchronously would freeze the UI for seconds.
|
|
/// </summary>
|
|
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");
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>📎 button click. Opens the system file picker (no filter — all files), then stages the result via IngestPickedFileAsync.</summary>
|
|
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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared by the 📎 button and drag-and-drop. Reads the file, enforces the 50 MB cap,
|
|
/// base64-encodes, sets _pendingAttachment* and shows the context bar with the filename
|
|
/// so the user knows it's staged.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Best-effort MIME mapping by file extension. Used to set AttachmentMimeType on send.</summary>
|
|
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();
|
|
|
|
/// <summary>Clears reply/edit state, drops the staged attachment, hides the context bar, empties the editor.</summary>
|
|
private void CancelContext()
|
|
{
|
|
_replyingToMessage = null;
|
|
_editingMessageId = null;
|
|
ClearPendingAttachment();
|
|
|
|
MainThread.BeginInvokeOnMainThread(() =>
|
|
{
|
|
ContextBar.IsVisible = false;
|
|
ContextBarLabel.Text = string.Empty;
|
|
MessageEntry.Text = string.Empty;
|
|
});
|
|
}
|
|
|
|
/// <summary>Enters reply mode. Next Send becomes a reply to this message + auto-mentions the original sender.</summary>
|
|
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();
|
|
});
|
|
}
|
|
|
|
/// <summary>Enters edit mode. Pre-fills the editor with the existing text; next Send fires SendEditMessage.</summary>
|
|
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);
|
|
|
|
/// <summary>Copies a relay://jump/{channel}/{message} URL to the clipboard. Pasting this in chat creates a tappable "Jump to message" embed.</summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wires the message-bubble context menu. On Windows: native RightTapped via HandlerChanged.
|
|
/// On mobile (no right click): double-tap as the fallback gesture.
|
|
/// </summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the action sheet shown on right-click of a message: Reply + Copy Link always;
|
|
/// Edit + Delete only for messages the current user owns.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Same gesture wiring as AttachMessageContextMenu, but for sidebar channel buttons.</summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>Action sheet for right-click on a channel: "View Permissions" always; "Delete Channel" only if CanManage.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Read-only summary dialog of channel meta and the user's resolved permissions. Full per-role editing UI is a future feature.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>"+" sidebar button. Walks the user through name → type → group, then fires SendCreateChannel.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ChannelListReceived handler. The _channelsInitialized guard ensures the auto-select
|
|
/// (jump to #welcome) only happens on the very first receive — after that, the user
|
|
/// stays on whatever channel they were viewing.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// New-message handler. Filters out frames addressed to other users (delivery is per
|
|
/// recipient), decrypts via TryDecryptAndParseContent, stores in both lookup dictionaries.
|
|
/// If the message is for the active channel → render immediately. If it's for a different
|
|
/// channel AND mentions the user → bump the sidebar 🔔 badge.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>True if message.Mentions contains this user's name OR "here" OR "everyone". Drives ping badges and bubble highlight.</summary>
|
|
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));
|
|
|
|
/// <summary>
|
|
/// Edit broadcast handler. Decrypts the new content, mutates the existing ChatMessage in
|
|
/// place, then rebuilds the matching Border's content via RebuildBubbleContent. No full
|
|
/// re-render — only this single bubble updates.
|
|
/// </summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>Tombstone handler. Flips IsDeleted on the ChatMessage and rebuilds the bubble to a "🗑 deleted" placeholder.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the header typing label and starts (or resets) a 3-second auto-clear timer
|
|
/// per typer. Ignores typing events for channels the user isn't currently viewing.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EditHistory response handler. Decrypts every prior version, formats them with timestamps,
|
|
/// and dumps into a DisplayAlert. (A scrollable popup is a future polish.)
|
|
/// </summary>
|
|
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");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// RSA-decrypt the payload using the user's private key, then JSON-parse to ChatMessageContent.
|
|
/// Falls back to treating the plaintext as raw text if JSON parse fails (compat with messages
|
|
/// sent before ChatMessageContent existed).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>MAUI hook: cleanly closes the WebSocket when the user navigates away from MainPage.</summary>
|
|
protected override void OnDisappearing()
|
|
{
|
|
_socket.Disconnect();
|
|
base.OnDisappearing();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuilds the sidebar from _channels. Groups by Group label, applies the selected-channel
|
|
/// highlight, adds the 🔒 suffix for read-only channels, and shows the 🔔N badge for channels
|
|
/// with unread mentions. Right-click context menu is attached per row.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Channel button click handler. Clears stale UI state (typing indicator, reply/edit
|
|
/// context), then either swaps to the RTC view (for Voice) or shows messages + requests
|
|
/// history if we haven't seen it yet. Always rebuilds the sidebar at the end so the
|
|
/// selected-channel highlight updates.
|
|
/// </summary>
|
|
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();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables/disables MessageEntry+SendButton based on the server-resolved CanPost. Updates
|
|
/// the placeholder so the user knows why the input is greyed out (in read-only channels).
|
|
/// </summary>
|
|
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.";
|
|
}
|
|
|
|
/// <summary>Cancels every pending typing-clear timer and hides the typing label. Called on channel switch.</summary>
|
|
private void ClearTypingIndicator()
|
|
{
|
|
foreach (var cts in _typingClearTimers.Values) cts.Cancel();
|
|
_typingClearTimers.Clear();
|
|
MainThread.BeginInvokeOnMainThread(() =>
|
|
{
|
|
TypingLabel.IsVisible = false;
|
|
TypingLabel.Text = string.Empty;
|
|
});
|
|
}
|
|
|
|
/// <summary>Full re-render of the current channel's scrollback. Called on channel switch or when history first arrives.</summary>
|
|
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) { }
|
|
|
|
/// <summary>
|
|
/// Renders ONE message as a Border bubble and appends it to MessagesLayout. Bubble alignment
|
|
/// (left vs right) is based on isOwn. mentionsMe adds a 2px violet stroke so the user can
|
|
/// spot pings while scrolling. Bubble is registered in _messageBubbles for later in-place
|
|
/// updates (edit, delete).
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>O(1) bubble update — swaps the Border's Content for a freshly-built one. Called by edit/delete handlers.</summary>
|
|
private void RebuildBubbleContent(Border bubble, ChatMessage message)
|
|
{
|
|
bubble.Content = BuildBubbleContent(message);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composes a single bubble's content. Order: tombstone short-circuit → optional reply
|
|
/// quote (tap = scroll to original) → sender label → MarkdownHelper.Render body →
|
|
/// inline attachment (image or file card) → URL embeds → footer (timestamp + (edited)).
|
|
/// Right-click menu is wired at the bubble level (in RenderSingleMessage), not here.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively walks an embed view tree (built by EmbedHelper) and binds the tap gesture on
|
|
/// any "Jump to message" label to ScrollToMessage. EmbedHelper can't do this itself because
|
|
/// it doesn't know about the message-bubble dictionary that ScrollToMessage uses.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Scrolls the bubble for the given messageId into view. No-op if the bubble isn't in this channel's render set.</summary>
|
|
private void ScrollToMessage(string messageId)
|
|
{
|
|
if (!_messageBubbles.TryGetValue(messageId, out var bubble)) return;
|
|
MainThread.BeginInvokeOnMainThread(async () =>
|
|
await MessagesScrollView.ScrollToAsync(bubble, ScrollToPosition.Start, animated: true));
|
|
}
|
|
|
|
/// <summary>Fires SendGetEditHistory. Server replies asynchronously with SocketEditHistoryResponse → HandleEditHistory.</summary>
|
|
private void RequestEditHistory(ChatMessage message)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(message.MessageId)) return;
|
|
_socket.SendGetEditHistory(message.MessageId, _currentChannelId ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// HybridWebView callback when JS does HybridWebView.SendRawMessage(string). The JS RTC
|
|
/// engine fires "rtc_page_ready" when it's done initialising — we use that to push the
|
|
/// current username/channelId into JS context so subsequent SDP exchanges work.
|
|
/// </summary>
|
|
private async void OnHybridWebViewRawMessageReceived(object sender, HybridWebViewRawMessageReceivedEventArgs e)
|
|
{
|
|
if (e.Message == "rtc_page_ready")
|
|
{
|
|
await _rtc.PushRtcContextToJsAsync();
|
|
return;
|
|
}
|
|
SafeSendRawToWebView($"JS → C#: {e.Message}");
|
|
}
|
|
|
|
/// <summary>Sends a message into the WebView with the call dispatched onto the UI thread. Swallows + logs send errors.</summary>
|
|
private void SafeSendRawToWebView(string message)
|
|
{
|
|
MainThread.BeginInvokeOnMainThread(() =>
|
|
{
|
|
try { hybridWebView.SendRawMessage(message); }
|
|
catch (Exception ex) { Console.WriteLine($"[{_username}] WebView send failed: {ex.Message}"); }
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client-side message model. Decrypted ChatMessageContent plus server-supplied metadata
|
|
/// (MessageId, IsEdited, IsDeleted) and client-side state (Timestamp, mutable Text/IsEdited
|
|
/// for edit handling).
|
|
/// </summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>Button subclass that carries the channel's Type/Group, used so the sidebar can re-render with metadata.</summary>
|
|
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 { }
|
|
}
|