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();
});
}
}