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:
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user