398 lines
No EOL
12 KiB
C#
398 lines
No EOL
12 KiB
C#
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;
|
|
|
|
/// <summary>A new connection would be queued; a non-empty queue also means no slot is available to newcomers.</summary>
|
|
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;
|
|
|
|
/// <summary>Reloading or leaving the page now would cancel a queued, pending or established connection.</summary>
|
|
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<Link> 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<bool>("isSecureContext") && !JS.IsUndefined("crypto.subtle");
|
|
_session = new TransferSession(Navigation, Crypto, BotCheckSolver, LoggerFactory.CreateLogger<TransferSession>());
|
|
_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 <PageTitle> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// While enabled, the browser asks before a reload, closing the tab or clicking the title (which reloads the
|
|
/// page) cancels the connection.
|
|
/// </summary>
|
|
private void SetLeaveGuard(bool enabled)
|
|
{
|
|
if (enabled == _isLeaveGuardActive)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_window ??= JS.Get<Window>("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<Task<string?>> 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<IBrowserFile> 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<bool> TryCopyAsync(string text)
|
|
{
|
|
try
|
|
{
|
|
using var navigator = JS.Get<Navigator>("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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders right away if the last render is long enough ago, otherwise once <see cref="MinRenderInterval" /> has
|
|
/// passed; changes in between are coalesced into that render.
|
|
/// </summary>
|
|
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();
|
|
});
|
|
}
|
|
} |