Files
mumh5/src/ui/ConnectDialog.svelte
T
kibiandClaude Opus 5.5 82972438fe 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>
2026-10-01 20:47:10 +02:00

149 lines
7.1 KiB
Svelte

<script lang="ts">
import Modal from './Modal.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
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();
// svelte-ignore state_referenced_locally
const initial = server;
// svelte-ignore state_referenced_locally
const pre = prefill ?? {};
let label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738);
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 = '';
if (!file) return;
iconError = '';
try { icon = await makeServerIcon(file); } catch { iconError = 'Could not read this image.'; }
}
const valid = $derived(host.trim() && username.trim() && port > 0 && port < 65536);
function build(): SavedServer {
// Accept "host:port" pasted into the host field
let h = host.trim(), p = Number(port);
const m = /^(.+):(\d+)$/.exec(h);
if (m && !h.includes(']')) { h = m[1]; p = Number(m[2]); }
return { id: initial?.id ?? crypto.randomUUID(), label: label.trim() || h, host: h, port: p, username: username.trim(), password, identityId: identityId || undefined, icon: icon || undefined };
}
function saveAndConnect(e: SubmitEvent) {
e.preventDefault();
if (!valid) return;
const s = build();
store.upsertServer(s);
// Reconnect with the new settings if it was connected
if (sessions.byServer(s.id)) sessions.close(s.id);
sessions.open(s);
onclose();
}
function remove() {
if (!initial) return;
sessions.close(initial.id);
store.removeServer(initial.id);
onclose();
}
</script>
<Modal title={initial ? 'Edit server' : 'Add a server'} {onclose}>
<form id="connect" onsubmit={saveAndConnect}>
{#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} 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>
{/if}
<label for="c-label">Label <span class="opt">(optional)</span></label>
<input id="c-label" bind:value={label} placeholder={host || 'My server'} />
<label for="c-icon">Icon <span class="opt">(optional)</span></label>
<div class="icon-row">
<span class="preview">
{#if icon}<img src={icon} alt="" />{:else}{(label || host || '?').trim().slice(0, 2).toUpperCase()}{/if}
</span>
<button id="c-icon" type="button" class="btn" onclick={() => iconPicker.click()}>{icon ? 'Change image' : 'Choose image'}</button>
{#if icon}<button type="button" class="btn" onclick={() => (icon = '')}>Remove</button>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
</div>
{#if iconError}<p class="err">{iconError}</p>{/if}
</form>
{#snippet footer()}
{#if initial}<button class="btn danger" onclick={remove}>Remove</button><span style="flex:1"></span>{/if}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" type="submit" form="connect" disabled={!valid}>Save and connect</button>
{/snippet}
</Modal>
<style>
.row { display: flex; gap: 8px; }
.port { width: 100px; flex: none; }
.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>