DataTransfer/DataTransfer.Client/Pages/Link.razor
2026-10-15 20:09:11 +02:00

446 lines
26 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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 &amp; 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;
}
}