446 lines
26 KiB
Text
446 lines
26 KiB
Text
@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;
|
||
}
|
||
}
|