using System.Diagnostics; using System.Net; using DataTransfer.Client.Services; using DataTransfer.Client.Shared; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Components.Web; using SpawnDev.BlazorJS; using SpawnDev.BlazorJS.Cryptography; using SpawnDev.BlazorJS.JSObjects; namespace DataTransfer.Client.Pages; public sealed partial class Link : ComponentBase, IAsyncDisposable { private const int MaxFilesPerSelection = 20; // Frames can arrive ten times per second; re-rendering the whole log for each would make the page sluggish. private static readonly TimeSpan MinRenderInterval = TimeSpan.FromMilliseconds(250); private TransferSession? _session; private SessionState _observedState; private string? _documentTitle; private Window? _window; private bool _isLeaveGuardActive; private long _lastRenderTimestamp; private bool _isRenderScheduled; private Timer? _ticker; private bool _cryptoSupported; private PendingAction _pending; private bool _copied; private bool _dragging; private bool _sendingMessage; private ChatEntry? _copiedEntry; private string? _joinCode; private string? _message; private string? _error; private enum PendingAction { None, Create, Join } private TransferSession Session => _session!; private ServerStatus? Load => Status.Current; /// A new connection would be queued; a non-empty queue also means no slot is available to newcomers. private bool IsServerFull => Load is { } load && (load.ActiveConnections >= load.MaxConnections || load.QueuedClients > 0); private string QueueAheadText => (Session.QueuePosition - 1) switch { <= 0 => "You are next", 1 => "1 person ahead of you", var ahead => $"{ahead} people ahead of you" }; private string Title => _session?.State switch { SessionState.Queued => $"Queue #{Session.QueuePosition} · DataTransfer", SessionState.Waiting => "Waiting for partner · DataTransfer", SessionState.Connected => "Connected · DataTransfer", _ => "DataTransfer" }; private string? DisplayCode => Session.Code is { } code ? ConnectionCode.Format(code) : null; private string RemainingTime => DisplayFormat.Countdown(Session.RemainingTime ?? TimeSpan.Zero); private int RemainingPercent => Session.CountdownDuration > TimeSpan.Zero ? (int)Math.Clamp((Session.RemainingTime ?? TimeSpan.Zero) / Session.CountdownDuration * 100, 0, 100) : 0; private bool IsBusy => _pending != PendingAction.None; /// Reloading or leaving the page now would cancel a queued, pending or established connection. private bool IsConnectionActive => _session?.State is SessionState.Queued or SessionState.Joining or SessionState.Waiting or SessionState.Connected; private int SentCount => Session.Entries.Count(entry => entry.Kind == ChatEntryKind.Outgoing); private int ReceivedCount => Session.Entries.Count(entry => entry.Kind == ChatEntryKind.Incoming); [Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private PortableCrypto Crypto { get; set; } = null!; [Inject] private BlazorJSRuntime JS { get; set; } = null!; [Inject] private ILoggerFactory LoggerFactory { get; set; } = null!; [Inject] private ILogger Logger { get; set; } = null!; [Inject] private ServerStatusMonitor Status { get; set; } = null!; [Inject] private BotCheckSolver BotCheckSolver { get; set; } = null!; public async ValueTask DisposeAsync() { Status.Changed -= OnStatusChanged; SetLeaveGuard(false); _window?.Dispose(); if (_ticker is not null) { await _ticker.DisposeAsync(); } if (_session is not null) { _session.Changed -= OnSessionChanged; await _session.DisposeAsync(); } } protected override void OnInitialized() { _cryptoSupported = JS.Get("isSecureContext") && !JS.IsUndefined("crypto.subtle"); _session = new TransferSession(Navigation, Crypto, BotCheckSolver, LoggerFactory.CreateLogger()); _session.Changed += OnSessionChanged; _ticker = new Timer(_ => OnTick(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)); Status.Changed += OnStatusChanged; Status.EnsureStarted(); } protected override void OnAfterRender(bool firstRender) { // The HeadOutlet is rendered statically, so cannot reach it from this non-prerendered page. var title = Title; if (title != _documentTitle) { _documentTitle = title; JS.Set("document.title", title); } SetLeaveGuard(IsConnectionActive); _lastRenderTimestamp = Stopwatch.GetTimestamp(); } /// /// While enabled, the browser asks before a reload, closing the tab or clicking the title (which reloads the /// page) cancels the connection. /// private void SetLeaveGuard(bool enabled) { if (enabled == _isLeaveGuardActive) { return; } _window ??= JS.Get("window"); if (enabled) { _window.OnBeforeUnload += OnBeforeUnload; } else { _window.OnBeforeUnload -= OnBeforeUnload; } _isLeaveGuardActive = enabled; } private static void OnBeforeUnload(BeforeUnloadEvent beforeUnload) { beforeUnload.PreventDefault(); // Older browsers only show the dialog when a return value is set; its text is no longer displayed. beforeUnload.ReturnValue = string.Empty; } private Task CreateAsync() => RunBusyAsync( PendingAction.Create, () => { _copied = false; return Session.CreateAsync(); }); private Task JoinAsync() => RunBusyAsync( PendingAction.Join, async () => { var error = await Session.JoinAsync(_joinCode); if (error is null && Session.State == SessionState.Connected) { _joinCode = null; } return error; }); private async Task RunBusyAsync(PendingAction pending, Func> action) { if (IsBusy) { return; } _pending = pending; _error = null; try { _error = await action(); } catch (Exception ex) { if (IsRateLimited(ex)) { Logger.LogWarning("Connecting was rate limited by the server"); _error = "Too many connections or connection attempts from your network. Please wait a minute and try again."; } else { Logger.LogError(ex, "Connecting failed"); _error = "Connecting to the server failed. Please try again."; } await Session.ResetAsync(); } finally { _pending = PendingAction.None; } } private static bool IsRateLimited(Exception exception) => exception is HttpRequestException { StatusCode: HttpStatusCode.TooManyRequests } || (exception is AggregateException aggregate && aggregate.InnerExceptions.Any(IsRateLimited)) || (exception.InnerException is { } inner && IsRateLimited(inner)); private async Task OnJoinKeyDownAsync(KeyboardEventArgs args) { if (args.Key == "Enter") { await JoinAsync(); } } private async Task OnMessageKeyDownAsync(KeyboardEventArgs args) { if (args.Key == "Enter" && (args.CtrlKey || args.MetaKey)) { await SendMessageAsync(); } } private async Task SendMessageAsync() { var text = _message; if (_sendingMessage || string.IsNullOrWhiteSpace(text)) { return; } _sendingMessage = true; try { var error = await Session.SendTextAsync(text); if (error is not null) { _error = error; } else if (_message == text) { _message = string.Empty; } } finally { _sendingMessage = false; } } private async Task OnFilesSelectedAsync(InputFileChangeEventArgs args) { _dragging = false; IReadOnlyList files; try { files = args.GetMultipleFiles(MaxFilesPerSelection); } catch (InvalidOperationException) { _error = $"Please select at most {MaxFilesPerSelection} files at once."; return; } await Session.SendFilesAsync(files); } private async Task CopyCodeAsync() { if (DisplayCode is not { } code) { return; } _copied = await TryCopyAsync(code); if (!_copied) { _error = "Copying failed. Please copy the code manually."; } } private async Task CopyEntryAsync(ChatEntry entry) { if (entry.Text is not { } text) { return; } if (await TryCopyAsync(text)) { _copiedEntry = entry; } else { _error = "Copying failed. Please select and copy the text manually."; } } private async Task TryCopyAsync(string text) { try { using var navigator = JS.Get("navigator"); using var clipboard = navigator.Clipboard; await clipboard.WriteText(text); return true; } catch (Exception ex) { Logger.LogWarning(ex, "Copying to the clipboard failed"); return false; } } private Task DisconnectAsync() => Session.DisconnectAsync(); private async Task ResetAsync() { _error = null; _copied = false; _copiedEntry = null; _dragging = false; _message = null; await Session.ResetAsync(); } private void OnSessionChanged() { // Connections opening or closing change the server load, so don't wait for the next poll. if (_session is { } session && session.State != _observedState) { _observedState = session.State; _ = Status.RefreshAsync(); } RequestRender(); } private void OnStatusChanged() => RequestRender(); private void OnTick() { if (_session?.State is SessionState.Waiting or SessionState.Connected) { RequestRender(); } } /// /// Renders right away if the last render is long enough ago, otherwise once has /// passed; changes in between are coalesced into that render. /// private void RequestRender() { if (_isRenderScheduled) { return; } _isRenderScheduled = true; var wait = MinRenderInterval - Stopwatch.GetElapsedTime(_lastRenderTimestamp); _ = InvokeAsync(async () => { if (wait > TimeSpan.Zero) { await Task.Delay(wait); } _isRenderScheduled = false; StateHasChanged(); }); } }