Initial version

This commit is contained in:
Sithis 2026-10-15 05:46:35 +02:00
commit e5b6226a85
68 changed files with 7338 additions and 0 deletions

View 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 &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;
}
}