Add the browser version, a toolbar, description previews and hints

Browser version
- Self-hosted proxy (server/) that serves the web build and bridges WebSocket
  connections to Mumble servers over TLS, with a server allowlist or allow-any mode
- WebSocket transport and a browser platform layer; identities live in the browser
- npm run build:web, dev:web, proxy and test:e2e:web; proxy unit tests

Interface
- Toolbar next to mute and deafen: description editor, settings, expand or collapse all
- Channels with a description show a marker; resting on the row previews it
- Collapse all keeps channels with people open
- Hints (title tooltips) can be switched on in a new Accessibility settings tab
- New identities can set the username suggested when connecting
- Own user information tells the client address from the one the server sees

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:47:10 +02:00
co-authored by Claude Opus 5.5
parent 74aaf635be
commit 82972438fe
50 changed files with 1485 additions and 80 deletions
+57 -2
View File
@@ -10,6 +10,7 @@
import { store } from '../lib/settings.svelte.ts';
import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -22,6 +23,39 @@
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// Resting on a channel that has a description for 1 second opens a quick preview next to its row
const hasDescription = $derived(!!(channel.description || channel.descriptionHash));
const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
let preview = $state<{ x: number; y: number } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
// Where the pointer is while it rests on the row; the preview opens there
let pointer = { x: 0, y: 0 };
function trackPointer(e: MouseEvent) {
pointer = { x: e.clientX, y: e.clientY };
}
function hoverRow(e: MouseEvent) {
if (!hasDescription) return;
trackPointer(e);
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
// Long descriptions are only announced by hash; the text is fetched now
session.loadDescription(channel.id);
// Just below and right of the pointer, kept inside the window
preview = { x: Math.max(8, Math.min(pointer.x + 12, window.innerWidth - 348)), y: Math.max(8, Math.min(pointer.y + 12, window.innerHeight - 340)) };
}, 1000);
}
// Short grace period, so the pointer can move from the row into the preview
function leaveRow() {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => (preview = null), 250);
}
function showDescription() {
clearTimeout(hoverTimer);
preview = null;
session.showChannel(channel.id, 'info');
ui.panelOpen = true;
}
// Single click previews, double click acts. The single action waits briefly so a
// double click does not also flash the preview.
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -69,7 +103,8 @@
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
ondragleave={() => (over = false)}
ondrop={onDrop}>
ondrop={onDrop}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
@@ -81,14 +116,25 @@
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
<button class="name" title="Click for details, double-click to join" {...channelClicks}
draggable={channel.id !== 0}
ondragstart={e => { drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragstart={e => { clearTimeout(hoverTimer); preview = null; drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragend={() => { drag.item = null; over = false; }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if}
</button>
{#if hasDescription}
<button class="desc" title="Show description" aria-label="Show description of {channel.name || 'Root'}"
onclick={showDescription}><span></span></button>
{/if}
</div>
{#if preview}
<div class="desc-pop" role="tooltip" style:left="{preview.x}px" style:top="{preview.y}px"
onmouseenter={() => clearTimeout(hoverTimer)} onmouseleave={leaveRow}>
<strong>{channel.name || 'Root'}</strong>
{#if descriptionHtml}<div class="html">{@html descriptionHtml}</div>{:else}<p>Loading description...</p>{/if}
</div>
{/if}
{#if users.length && expanded}
<ul class="users">
@@ -144,6 +190,15 @@
.caret.collapsed svg { transform: rotate(-90deg); }
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.desc { flex: none; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; }
.desc span { width: 9px; height: 9px; background: var(--warn); }
.desc:hover span { outline: 2px solid var(--warn); outline-offset: 1px; }
.desc-pop { position: fixed; z-index: 40; width: 340px; max-width: calc(100vw - 16px); max-height: 320px; overflow: auto; padding: 10px 12px;
background: var(--bg-2); border: 1px solid var(--line); color: var(--text); font-size: 14px; box-shadow: 0 8px 32px rgb(0 0 0 / 0.4); }
.desc-pop strong { display: block; margin-bottom: 6px; }
.desc-pop p { margin: 0; color: var(--text-dim); }
.desc-pop .html { overflow-wrap: anywhere; user-select: text; }
.desc-pop .html :global(img) { max-width: 100%; }
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
+47 -8
View File
@@ -4,6 +4,8 @@
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
import { isWeb } from '../lib/native.ts';
import { proxyInfo } from '../lib/web.svelte.ts';
// prefill: values for a new server, e.g. picked from the public list
let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props();
@@ -15,14 +17,41 @@
let label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738);
// New servers start with the name you used last
let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
// The name chosen for an identity when it was created, if any
const identityName = (id: string) => store.identityNames[identities.byId(id || undefined)?.id ?? ''] ?? '';
// New servers start with the identity's name, else the name you used last
const firstName = initial?.username ?? (identityName('') || store.servers.at(-1)?.username || '');
let username = $state(firstName);
// Picking another identity swaps the suggestion, unless the name was typed by hand
let suggested = firstName;
function identityChanged() {
const next = identityName(identityId);
if (next && (!username.trim() || username === suggested)) username = suggested = next;
}
let icon = $state(initial?.icon ?? '');
let iconError = $state('');
let iconPicker: HTMLInputElement;
// In the browser the proxy decides which servers can be reached; offer those instead of free text
const listed = $derived(isWeb && !proxyInfo.any ? proxyInfo.servers : null);
const pick = $derived(listed ? `${host}:${port}` : '');
function choose(value: string) {
const s = listed?.find(x => `${x.host}:${x.port}` === value);
if (!s) return;
// Keep a label the user typed; replace one that came from the previous choice
if (!label.trim() || listed?.some(x => x.label === label)) label = s.label;
host = s.host;
port = s.port;
}
// Refreshed per dialog, so a changed allowlist shows up without a reload
if (isWeb) proxyInfo.load();
// A new server starts on the first one the proxy offers
$effect(() => {
if (!initial && listed?.length && !listed.some(x => x.host === host && x.port === port)) choose(`${listed[0].host}:${listed[0].port}`);
});
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
@@ -62,18 +91,27 @@
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}>
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
{#if listed}
<label for="c-host">Server</label>
<select id="c-host" value={pick} onchange={e => choose(e.currentTarget.value)}>
{#each listed as s (`${s.host}:${s.port}`)}<option value={`${s.host}:${s.port}`}>{s.label === s.host ? '' : `${s.label} - `}{s.host}{s.port !== 64738 ? `:${s.port}` : ''}</option>{/each}
</select>
<p class="note">This proxy connects to the servers listed here.</p>
{:else}
<label for="c-host">Address</label>
<div class="row">
<input id="c-host" bind:value={host} placeholder="mumble.example.com" autocomplete="off" required />
<input class="port" type="number" bind:value={port} min="1" max="65535" aria-label="Port" />
</div>
{/if}
{#if isWeb && proxyInfo.error}<p class="err">{proxyInfo.error}</p>{/if}
<label for="c-user">Username</label>
<input id="c-user" bind:value={username} autocomplete="username" required />
<label for="c-pass">Password <span class="opt">(optional)</span></label>
<input id="c-pass" type="password" bind:value={password} autocomplete="current-password" />
{#if identities.list.length > 1}
<label for="c-id">Identity</label>
<select id="c-id" bind:value={identityId}>
<select id="c-id" bind:value={identityId} onchange={identityChanged}>
<option value="">Default ({identities.default?.name})</option>
{#each identities.list as i (i.id)}<option value={i.id}>{i.name}</option>{/each}
</select>
@@ -104,6 +142,7 @@
.icon-row { display: flex; align-items: center; gap: 8px; }
.preview { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; overflow: hidden; }
.preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.note { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.err { color: var(--danger); font-size: 13px; margin: 6px 0 0; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
</style>
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { native, type IdentitySummary } from '../lib/native.ts';
import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
@@ -20,7 +20,7 @@
function exportId(i: IdentitySummary) {
ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: 'Choose location...', input: 'password',
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: isWeb ? 'Download' : 'Choose location...', input: 'password',
text: 'The .p12 file contains your private key. Protect it with a password; the desktop Mumble client can import it.',
onsubmit: pw => act(async () => {
const path = await native!.identities.exportP12(i.id, pw);
+15 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts';
import { native, isWeb, type IdentitySummary } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
@@ -44,9 +45,15 @@
}
}
// Suggest the identity's name as the username in the connect dialog
let useAsUsername = $state(true);
const create = (e: SubmitEvent) => {
e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
run(async () => {
const created = await native!.identities.create(name.trim(), email.trim());
if (useAsUsername) store.setIdentityName(created.id, name);
return created;
}, 'backup');
};
const importFile = async (e: SubmitEvent) => {
@@ -93,6 +100,7 @@
<Modal {title} onclose={mode === 'setup' ? () => {} : onclose} width={560}>
{#if step === 'welcome'}
<p>Mumble recognizes you by a <strong>certificate</strong>, not by a password. Registered names, admin rights and friends lists on servers are tied to it, so it is worth setting up once and keeping a backup.</p>
{#if isWeb}<p class="dim">In the browser the certificate is kept in this browser's storage and passed to the proxy at {location.host} when you connect, which presents it to the Mumble server.</p>{/if}
<div class="choices">
<button class="choice" onclick={() => (step = 'create')}>
<Icon name="plus" /><span><strong>Create a new identity</strong><small>Start fresh with a new certificate</small></span>
@@ -116,7 +124,8 @@
<p>The name is stored in the certificate and helps you tell identities apart. Servers show the username you enter when connecting, not this.</p>
<label for="w-name">Name</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. kibi" />
<input id="w-name" bind:value={name} required autofocus placeholder="e.g. your nickname" />
<label class="check"><input type="checkbox" bind:checked={useAsUsername} /> Use this name as my username when connecting with this identity</label>
<label for="w-email">Email <span class="opt">(optional, stored in the certificate)</span></label>
<input id="w-email" type="email" bind:value={email} />
</form>
@@ -130,7 +139,7 @@
{:else if step === 'backup' && result}
<p>Identity <strong>{result.name}</strong> is ready.</p>
<p class="mono">{result.fingerprint}</p>
<p>If this computer dies, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<p>{isWeb ? 'If this browser\'s data is cleared' : 'If this computer dies'}, the certificate goes with it, and so do your registrations. Save a password-protected backup now; the desktop Mumble client can import it too.</p>
<label for="w-bpass">Backup password <span class="opt">(at least 8 characters)</span></label>
<input id="w-bpass" type="password" bind:value={backupPassword} autocomplete="new-password" />
<label for="w-bconf">Repeat password</label>
@@ -163,6 +172,8 @@
</Modal>
<style>
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; }
p { color: var(--text-dim); margin: 10px 0 0; }
p strong { color: var(--text); }
.choices { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
+4 -3
View File
@@ -2,10 +2,11 @@
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts';
import { desktop as native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { identities } from '../lib/identities.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
@@ -95,11 +96,11 @@
});
}
// Connects right away with the name used last; the server is saved so it shows in the rail.
// Connects right away with the name used last (or the default identity's name); the server is saved so it shows in the rail.
// Without a name to reuse, the add dialog asks for one.
function connect(s: PublicServer) {
const saved = store.servers.find(x => x.host === s.host && x.port === s.port);
const username = store.servers.at(-1)?.username;
const username = store.servers.at(-1)?.username || store.identityNames[identities.default?.id ?? ''];
if (!saved && !username) return add(s);
const server = saved ?? { id: crypto.randomUUID(), label: s.name || s.host, host: s.host, port: s.port, username: username!, password: '' };
if (!saved) store.upsertServer(server);
+8
View File
@@ -8,6 +8,7 @@
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu, changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
@@ -45,6 +46,13 @@
editing = true;
}
// After the reset above, so a request from the toolbar survives the panel switching to the profile
$effect(() => {
if (!ui.editDescription || !isSelf) return;
ui.editDescription = false;
startEdit();
});
function saveComment() {
session.setComment(draft);
editing = false;
+3 -1
View File
@@ -3,6 +3,7 @@
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings, onbrowse }:
@@ -88,7 +89,8 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>
<!-- The public list and its pings need the desktop app -->
{#if desktop}<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>{/if}
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
+10 -6
View File
@@ -5,14 +5,14 @@
import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { native, desktop, isWeb } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
import { previewMode } from '../lib/preview.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab);
let tab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
@@ -39,11 +39,12 @@
}
}
type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities';
type Tab = 'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'accessibility', label: 'Accessibility' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
@@ -62,6 +63,9 @@
<SoundSettings />
{:else if tab === 'appearance'}
<AppearanceSettings />
{:else if tab === 'accessibility'}
<label class="check"><input type="checkbox" bind:checked={s.hints} onchange={() => store.saveSettings()} /> Show hints when the pointer rests on buttons and icons</label>
<p class="help">Hints are the small labels that appear on hover. Screen readers get the names either way.</p>
{:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label>
@@ -91,7 +95,7 @@
<!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
<select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
<option value="host" disabled={!hasUpload}>Fetched by the upload host (keeps your IP private){hasUpload ? '' : ' - needs an upload host and key'}</option>
<option value="direct">Fetched by this computer (linked sites see your IP)</option>
<option value="direct" disabled={!desktop}>Fetched by this computer (linked sites see your IP){desktop ? '' : ' - needs the desktop app'}</option>
<option value="off">Off</option>
</select>
<label for="s-yt">YouTube links</label>
@@ -101,7 +105,7 @@
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
</select>
{:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.{isWeb ? ' Certificates are kept in this browser and passed to the proxy when you connect.' : ''}</p>
<IdentityList />
{/if}
+35 -3
View File
@@ -43,6 +43,20 @@
]);
}
function editDescription() {
if (!self) return;
session.showUser(self.session);
ui.editDescription = true;
ui.panelOpen = true;
}
// One button for the whole tree: open everything, then close everything
let allOpen = $state(false);
function toggleAll() {
allOpen = !allOpen;
session.setAllExpanded(allOpen);
}
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -130,6 +144,7 @@
<span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
</span>
{/if}
<div class="tools">
<button class="icon-btn info" title="Server information" aria-label="Server information"
disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
<Icon name="info" size={18} />
@@ -144,6 +159,20 @@
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Change your description" aria-label="Change your description" disabled={!self} onclick={editDescription}>
<Icon name="message" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title="Configure" aria-label="Configure" onclick={() => (ui.settingsOpen = true)}>
<Icon name="settings" size={18} />
</button>
<span class="sep"></span>
<button class="icon-btn" title={allOpen ? 'Collapse all channels' : 'Expand all channels'} aria-label={allOpen ? 'Collapse all channels' : 'Expand all channels'}
disabled={session.status !== 'connected'} onclick={toggleAll}>
<Icon name="filter" size={18} />
</button>
</div>
</footer>
</aside>
@@ -174,15 +203,18 @@
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); }
/* One row when the sidebar is wide enough, otherwise the buttons go under the profile */
.me { flex: none; min-height: var(--footer-h); display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; padding: 4px 8px; background: var(--bg-0); }
.tools { flex: none; max-width: 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
.sep { width: 1px; height: 20px; margin: 0 4px; background: var(--line); }
/* Stacked layout: the bar leads the stack, like the desktop client's toolbar */
.sidebar.fill .me { order: -1; border-bottom: 1px solid var(--line); }
.sidebar.fill .me .profile, .sidebar.fill .me .offline-who { display: none; }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile { flex: 1 1 110px; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.offline-who { padding: 2px 2px 2px 6px; color: var(--text-dim); }
.offline-who { flex: 1 1 110px; padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style>
+12 -1
View File
@@ -23,6 +23,11 @@
const client = session.client;
const user = $derived(session.user(who));
const isSelf = who === session.self?.session;
// Browser build: our address as the web proxy saw it
// A proxy on this same machine connects from our own address, so what the server sees is ours
const viaProxy = session.serverCert?.clientAddress ?? '';
const ownAddress = /^(127\.|::1$|localhost$)/.test(viaProxy) ? '' : viaProxy;
const off = client?.on('userStats', msg => {
if (msg.session !== who) return;
@@ -84,7 +89,13 @@
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'n/a'}</dd>
<dt>Pings received</dt><dd>{stats.tcp_packets ?? 0} over TCP, {stats.udp_packets ?? 0} over UDP</dd>
{#if stats.bandwidth != null}<dt>Bandwidth</dt><dd>{formatBytes(stats.bandwidth)}/s</dd>{/if}
{#if stats.address?.length}<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>{/if}
{#if isSelf && ownAddress}
<!-- Through the web proxy the server only knows the proxy's address -->
<dt>Address</dt><dd class="mono">{ownAddress}</dd>
{#if stats.address?.length && address(stats.address) !== ownAddress}<dt>Seen by the server</dt><dd><span class="mono">{address(stats.address)}</span> (the web proxy)</dd>{/if}
{:else if stats.address?.length}
<dt>Address</dt><dd class="mono">{address(stats.address)}</dd>
{/if}
</dl>
{#if stats.version}