Initial version
This commit is contained in:
commit
e5b6226a85
68 changed files with 7338 additions and 0 deletions
446
DataTransfer.Client/Pages/Link.razor
Normal file
446
DataTransfer.Client/Pages/Link.razor
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
@page "/"
|
||||
@rendermode @(new InteractiveWebAssemblyRenderMode(false))
|
||||
|
||||
@if (!_cryptoSupported)
|
||||
{
|
||||
<header class="page-head">
|
||||
<h1>Start a transfer</h1>
|
||||
</header>
|
||||
<div class="notice notice-danger" role="alert">
|
||||
<Icon Name="alert"/>
|
||||
<div class="notice-text">
|
||||
<strong>End-to-end encryption is not available.</strong>
|
||||
This browser cannot encrypt end-to-end. Please use a current browser and open this site via HTTPS.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="notice notice-danger" role="alert" data-testid="error">
|
||||
<Icon Name="alert"/>
|
||||
<span class="notice-text">@_error</span>
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-icon" title="Dismiss" aria-label="Dismiss"
|
||||
@onclick="() => _error = null">
|
||||
<Icon Name="close"/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (Session.State)
|
||||
{
|
||||
case SessionState.Idle:
|
||||
case SessionState.Joining:
|
||||
<header class="page-head">
|
||||
<h1>Start a transfer</h1>
|
||||
<p>Connect two browsers with a one-time code. Messages and files are encrypted on this device before
|
||||
they are sent.</p>
|
||||
</header>
|
||||
|
||||
<div class="panel connect">
|
||||
<section class="connect-option">
|
||||
<h2 class="connect-title"><span class="connect-icon"><Icon Name="plus"/></span>Create a connection
|
||||
</h2>
|
||||
<p class="connect-text">
|
||||
Generates a one-time code. Share it with the person you want to exchange messages and files
|
||||
with.
|
||||
</p>
|
||||
@if (Load is { } load && IsServerFull)
|
||||
{
|
||||
<p class="capacity-note" data-testid="capacity-note">
|
||||
<Icon Name="clock"/>
|
||||
<span>
|
||||
The server is full (@load.ActiveConnections/@load.MaxConnections connections in use@(load.QueuedClients > 0 ? $", {load.QueuedClients} waiting" : null)).
|
||||
You will be placed in a queue.
|
||||
</span>
|
||||
</p>
|
||||
}
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary" disabled="@IsBusy" @onclick="CreateAsync"
|
||||
data-testid="create">
|
||||
@if (_pending == PendingAction.Create)
|
||||
{
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>@(Session.IsVerifyingBrowser ? "Verifying browser…" : "Creating…")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Create connection</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="connect-divider" aria-hidden="true"><span>or</span></div>
|
||||
|
||||
<section class="connect-option">
|
||||
<h2 class="connect-title"><span class="connect-icon"><Icon Name="join"/></span>Join a connection
|
||||
</h2>
|
||||
<p class="connect-text">Enter the code you received.</p>
|
||||
<div class="join-row">
|
||||
<input class="input code-input" placeholder="ABCDE-FGHJK" maxlength="20"
|
||||
autocomplete="off" spellcheck="false" aria-label="Connection code"
|
||||
data-testid="join-code"
|
||||
@bind="_joinCode" @bind:event="oninput" @onkeydown="OnJoinKeyDownAsync"/>
|
||||
<button type="button" class="btn btn-primary" disabled="@IsBusy" @onclick="JoinAsync"
|
||||
data-testid="join">
|
||||
@if (_pending == PendingAction.Join)
|
||||
{
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Joining…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Join</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<span class="step-no">01</span>
|
||||
<div><strong>Create</strong> One side creates a connection and shares the code.</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-no">02</span>
|
||||
<div><strong>Join</strong> The other side enters the code to connect.</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-no">03</span>
|
||||
<div><strong>Verify & transfer</strong> Compare the safety code, then send messages and files.
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
break;
|
||||
|
||||
case SessionState.Queued:
|
||||
<header class="page-head">
|
||||
<h1>Waiting for a free slot</h1>
|
||||
<p>
|
||||
The server has reached its connection limit. Your connection is created automatically as soon as
|
||||
another
|
||||
one closes – keep this page open.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="panel waiting" aria-labelledby="queue-title">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title" id="queue-title">Queue</h2>
|
||||
</div>
|
||||
<div class="panel-body waiting-body">
|
||||
<div class="queue-position" aria-live="polite">
|
||||
<span class="queue-number" data-testid="queue-position">@Session.QueuePosition</span>
|
||||
<div class="queue-text">
|
||||
<strong>@QueueAheadText</strong>
|
||||
<span class="muted">Position in the queue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waiting-status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Waiting for a connection to close…</span>
|
||||
@if (Load is { } queueLoad)
|
||||
{
|
||||
<span class="waiting-expiry">
|
||||
<span class="mono">@queueLoad.ActiveConnections/@queueLoad.MaxConnections</span> connections in use
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button type="button" class="btn btn-danger" @onclick="ResetAsync" data-testid="leave-queue">Leave
|
||||
queue
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
break;
|
||||
|
||||
case SessionState.Waiting:
|
||||
<header class="page-head">
|
||||
<h1>Share the code</h1>
|
||||
<p>Give this code to the person you want to connect with, for example by phone or messenger. It can only
|
||||
be used once.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel waiting" aria-labelledby="code-title">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title" id="code-title">Connection code</h2>
|
||||
</div>
|
||||
<div class="panel-body waiting-body">
|
||||
<div class="code-display">
|
||||
<div class="code-chars" data-testid="connection-code">
|
||||
@foreach (var c in DisplayCode ?? string.Empty)
|
||||
{
|
||||
<span class="@(c == '-' ? "code-sep" : "code-char")">@c</span>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn" @onclick="CopyCodeAsync">
|
||||
<Icon Name="@(_copied ? "check" : "copy")"/>
|
||||
<span>@(_copied ? "Copied" : "Copy code")</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="waiting-status">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span data-testid="waiting-status">@(Session.IsPeerJoining ? "The other person joined. Securing the connection…" : "Waiting for the other person to join…")</span>
|
||||
<span class="waiting-expiry">Expires in <span class="mono">@RemainingTime</span></span>
|
||||
</div>
|
||||
<div class="progress countdown @(RemainingPercent <= 20 ? "is-low" : null)" aria-hidden="true">
|
||||
<div class="progress-bar" style="width: @(RemainingPercent)%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button type="button" class="btn btn-danger" @onclick="ResetAsync">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
break;
|
||||
|
||||
default:
|
||||
var connected = Session.State == SessionState.Connected;
|
||||
|
||||
@if (connected)
|
||||
{
|
||||
<section class="panel session">
|
||||
<div class="session-main">
|
||||
<h1 class="session-state"><span class="status status-online"
|
||||
data-testid="status">Connected</span></h1>
|
||||
<dl class="session-facts">
|
||||
<div>
|
||||
<dt>Code</dt>
|
||||
<dd class="mono">@DisplayCode</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Safety code</dt>
|
||||
<dd class="mono safety-code" data-testid="safety-code">@Session.SafetyCode</dd>
|
||||
</div>
|
||||
<div
|
||||
title="The connection closes after @DisplayFormat.Duration(Session.InactivityTimeout) without transfers.">
|
||||
<dt>Idle timeout · @DisplayFormat.Duration(Session.InactivityTimeout)</dt>
|
||||
<dd><span class="mono" data-testid="remaining">@RemainingTime</span> left</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button type="button" class="btn btn-danger btn-sm" @onclick="DisconnectAsync"
|
||||
data-testid="disconnect">
|
||||
<Icon Name="power"/>
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="session-hint">
|
||||
<Icon Name="shield"/>
|
||||
<span>
|
||||
Compare the safety code with your partner over another channel (e.g. by phone). If both codes match,
|
||||
nobody else – not even this server – can read your messages and files.
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel session" role="status">
|
||||
<div class="session-main">
|
||||
<h1 class="session-state"><span class="status status-offline">Disconnected</span></h1>
|
||||
<div class="session-closed">
|
||||
<span data-testid="closed-message">@Session.ClosedMessage</span>
|
||||
@if (Session.HasPeer)
|
||||
{
|
||||
<span
|
||||
class="muted">Received files stay available until you start a new connection.</span>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" @onclick="ResetAsync"
|
||||
data-testid="new-connection">
|
||||
New connection
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (Session.HasPeer)
|
||||
{
|
||||
<div class="workspace @(connected ? null : "is-closed")">
|
||||
@if (connected)
|
||||
{
|
||||
<section class="panel send" aria-labelledby="send-title">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title" id="send-title">Send</h2>
|
||||
</div>
|
||||
<div class="panel-body send-body">
|
||||
<label
|
||||
class="dropzone @(_dragging ? "is-dragging" : null) @(Session.IsSendingFiles ? "is-busy" : null)"
|
||||
@ondragenter="() => _dragging = true" @ondragleave="() => _dragging = false"
|
||||
@ondrop="() => _dragging = false"
|
||||
@ondragover:preventDefault="@Session.IsSendingFiles"
|
||||
@ondrop:preventDefault="@Session.IsSendingFiles">
|
||||
@if (Session.IsSendingFiles)
|
||||
{
|
||||
<span class="spinner dropzone-spinner" aria-hidden="true"></span>
|
||||
<strong>Sending files…</strong>
|
||||
<span>You can add more files when these are sent.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Icon Name="upload" Class="dropzone-icon"/>
|
||||
<strong>Drop files here or <span class="dropzone-browse">browse</span></strong>
|
||||
<span>Up to @MaxFilesPerSelection files at once, max. @(TransferProtocol.MaxFileBytes / (1024 * 1024)) MB each</span>
|
||||
}
|
||||
<InputFile class="dropzone-input" multiple OnChange="OnFilesSelectedAsync"
|
||||
disabled="@Session.IsSendingFiles"
|
||||
title="Choose files to send" data-testid="file-input"/>
|
||||
</label>
|
||||
|
||||
<div class="send-message">
|
||||
<label class="field-label" for="message">Message</label>
|
||||
<textarea id="message" class="input" rows="4"
|
||||
maxlength="@TransferProtocol.MaxMessageLength"
|
||||
placeholder="Text, links, notes…" data-testid="message-input"
|
||||
@bind="_message" @bind:event="oninput"
|
||||
@onkeydown="OnMessageKeyDownAsync"></textarea>
|
||||
<div class="send-actions">
|
||||
<span class="hint"><kbd>Ctrl</kbd> + <kbd>Enter</kbd> to send</span>
|
||||
<button type="button" class="btn btn-primary"
|
||||
disabled="@(_sendingMessage || string.IsNullOrWhiteSpace(_message))"
|
||||
@onclick="SendMessageAsync" data-testid="send">
|
||||
<span>Send</span>
|
||||
<Icon Name="arrow-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="panel log" aria-labelledby="log-title">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title" id="log-title">Transfers</h2>
|
||||
<span class="log-summary">
|
||||
<span><Icon Name="arrow-up"/>@SentCount sent</span>
|
||||
<span><Icon Name="arrow-down"/>@ReceivedCount received</span>
|
||||
</span>
|
||||
</div>
|
||||
@* column-reverse keeps the log scrolled to the newest entry without any JavaScript. *@
|
||||
<div class="log-scroll" aria-live="polite" data-testid="chat-log">
|
||||
@if (Session.Entries.Count == 0)
|
||||
{
|
||||
<div class="log-empty">
|
||||
<Icon Name="inbox"/>
|
||||
<strong>Nothing transferred yet</strong>
|
||||
<span>Messages and files are encrypted in your browser before they leave it.</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ol class="log-entries">
|
||||
@foreach (var entry in Session.Entries)
|
||||
{
|
||||
@if (entry.Kind == ChatEntryKind.System)
|
||||
{
|
||||
<li class="entry entry-system @(entry.Error is null ? null : "is-error")">
|
||||
<span class="entry-dir"><Icon
|
||||
Name="@(entry.Error is null ? "info" : "alert")"/></span>
|
||||
<div class="entry-body">@entry.Text</div>
|
||||
<div class="entry-meta">
|
||||
<time class="entry-time">@entry.Time.ToString("t")</time>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
var outgoing = entry.Kind == ChatEntryKind.Outgoing;
|
||||
<li class="entry @(outgoing ? "entry-out" : "entry-in")">
|
||||
<span class="entry-dir" title="@(outgoing ? "Sent" : "Received")">
|
||||
<Icon Name="@(outgoing ? "arrow-up" : "arrow-down")"/>
|
||||
<span
|
||||
class="visually-hidden">@(outgoing ? "Sent" : "Received")</span>
|
||||
</span>
|
||||
<div class="entry-body">
|
||||
@if (entry.IsFile)
|
||||
{
|
||||
<div class="entry-file" title="@entry.FileName">
|
||||
<Icon Name="file"/>
|
||||
<span class="entry-name">@entry.FileName</span>
|
||||
</div>
|
||||
@if (entry.IsRiskyFile)
|
||||
{
|
||||
<div class="entry-warning" role="note" data-testid="risky-file-warning">
|
||||
<Icon Name="alert"/>
|
||||
<span>
|
||||
This file type can run programs or scripts on your device. Only open it if
|
||||
you know the sender and expected this file.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (entry.Error is not null)
|
||||
{
|
||||
<div class="entry-error">@entry.Error</div>
|
||||
}
|
||||
else if (entry.IsTransferring)
|
||||
{
|
||||
<div class="progress entry-progress" role="progressbar"
|
||||
aria-label="@(outgoing ? "Sending" : "Receiving") @entry.FileName"
|
||||
aria-valuenow="@entry.ProgressPercent"
|
||||
aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar"
|
||||
style="width: @(entry.ProgressPercent)%"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="entry-text">@entry.Text</div>
|
||||
}
|
||||
</div>
|
||||
<div class="entry-meta">
|
||||
<span
|
||||
class="entry-size">@(entry.IsFile ? DisplayFormat.Bytes(entry.FileSize) : null)</span>
|
||||
<time class="entry-time">@entry.Time.ToString("t")</time>
|
||||
</div>
|
||||
<div class="entry-action">
|
||||
@if (!entry.IsFile)
|
||||
{
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-icon"
|
||||
title="Copy text" aria-label="Copy text"
|
||||
@onclick="() => CopyEntryAsync(entry)">
|
||||
<Icon
|
||||
Name="@(ReferenceEquals(_copiedEntry, entry) ? "check" : "copy")"/>
|
||||
</button>
|
||||
}
|
||||
else if (entry.Error is not null)
|
||||
{
|
||||
<span class="tag tag-danger">Failed</span>
|
||||
}
|
||||
else if (entry.IsTransferring)
|
||||
{
|
||||
<span
|
||||
class="tag tag-progress mono">@entry.ProgressPercent%</span>
|
||||
}
|
||||
else if (entry.DownloadUrl is not null)
|
||||
{
|
||||
<a class="btn btn-sm" href="@entry.DownloadUrl"
|
||||
download="@entry.FileName" title="Save @entry.FileName">
|
||||
<Icon Name="download"/>
|
||||
<span>Save</span>
|
||||
<span class="visually-hidden">@entry.FileName</span>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tag tag-success"><Icon
|
||||
Name="check"/>@(outgoing ? "Sent" : "Received")</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue