Initial version
This commit is contained in:
commit
e5b6226a85
68 changed files with 7338 additions and 0 deletions
398
DataTransfer.Client/Pages/Link.razor.cs
Normal file
398
DataTransfer.Client/Pages/Link.razor.cs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue