Initial commit: mumh5, a modern Mumble client

Electron desktop app with a Svelte 5 interface for any Mumble server.

- Mumble protocol core: TLS, handshake, channels, users, text, plugin data,
  client-side pacing of Murmur's rate limits
- Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk,
  always-on, devices, per-user volume and local mute
- Several servers at once, voice on one; server rail with icons and ordering
- Chat: channels, direct messages, side chat, file sharing through f0ckm,
  inline images without it, click-to-play YouTube
- Profiles with rich descriptions, registration, rename, nicknames,
  connection information and moderation menus
- Identity wizard, multiple identities, PKCS#12 import/export, desktop
  Mumble certificate import, certificate pinning and viewer
- Tray icon with voice state
- Unit, server and end-to-end tests against a real Murmur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 23:28:43 +02:00
co-authored by Claude Opus 5.5
commit 6d29ee1485
85 changed files with 22997 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
let { name, size = 32, speaking = false }: { name: string; size?: number; speaking?: boolean } = $props();
const hue = $derived([...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 360);
</script>
<span class="avatar" class:speaking style:width="{size}px" style:height="{size}px"
style:font-size="{Math.round(size * 0.45)}px" style:background="hsl({hue} 45% 38%)">
{(name.trim()[0] ?? '?').toUpperCase()}
</span>
<style>
.avatar {
flex: none; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; color: #fff; user-select: none; box-shadow: 0 0 0 2px transparent;
transition: box-shadow 80ms;
}
.speaking { box-shadow: 0 0 0 2px var(--speaking); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
const p = $derived(session.certPrompt!);
</script>
<Modal title="Server certificate changed" onclose={() => (session.certPrompt = null)} width={520}>
<p>The certificate of <strong>{p.hostPort}</strong> is different from the one it had before. This happens when a server is reinstalled, but it can also mean someone is intercepting the connection. Your password has not been sent.</p>
<p class="k">Previous</p><code>{p.previous}</code>
<p class="k">Now</p><code>{p.fingerprint}</code>
{#snippet footer()}
<button class="btn" onclick={() => (session.certPrompt = null)}>Cancel</button>
<button class="btn danger" onclick={() => session.acceptCertificate()}>Trust new certificate</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
.k { margin: 12px 0 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
code { display: block; padding: 8px; background: var(--bg-0); font: 12px var(--mono); overflow-wrap: anywhere; }
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import Modal from './Modal.svelte';
import type { CertDetails } from '../core/transport.ts';
let { title, chain, status, onclose }:
{ title: string; chain: CertDetails[]; status?: { ok: boolean; text: string }; onclose: () => void } = $props();
let index = $state(0);
const cert = $derived(chain[index]);
// "CN=foo\nO=bar" -> [["CN","foo"],["O","bar"]]
const parts = (dn: string) => dn.split('\n').filter(Boolean).map(l => {
const i = l.indexOf('=');
return [l.slice(0, i), l.slice(i + 1)];
});
const names: Record<string, string> = { CN: 'Common name', O: 'Organization', OU: 'Unit', C: 'Country', ST: 'State', L: 'Locality', emailAddress: 'Email' };
const date = (iso: string) => new Date(iso).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
const expired = $derived(cert ? new Date(cert.validTo) < new Date() : false);
function copyPem() { navigator.clipboard.writeText(cert.pem); }
</script>
<Modal {title} {onclose} width={620}>
{#if status}<p class="status" class:ok={status.ok}>{status.text}</p>{/if}
{#if chain.length > 1}
<div class="tabs" role="tablist">
{#each chain as c, i (c.fingerprint256)}
<button role="tab" aria-selected={i === index} class:active={i === index} onclick={() => (index = i)}>
{parts(c.subject).find(p => p[0] === 'CN')?.[1] ?? `Certificate ${i + 1}`}
</button>
{/each}
</div>
{/if}
{#if cert}
<h3>Subject</h3>
<dl>{#each parts(cert.subject) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Issuer {#if cert.selfSigned}<span class="tag">self-signed</span>{/if}</h3>
<dl>{#each parts(cert.issuer) as [k, v] (k + v)}<dt>{names[k] ?? k}</dt><dd>{v}</dd>{/each}</dl>
<h3>Validity {#if expired}<span class="tag bad">expired</span>{/if}</h3>
<dl>
<dt>Not before</dt><dd>{date(cert.validFrom)}</dd>
<dt>Not after</dt><dd>{date(cert.validTo)}</dd>
</dl>
<h3>Details</h3>
<dl>
<dt>Public key</dt><dd>{cert.keyType.toUpperCase()}{cert.keyBits ? ` ${cert.keyBits} bit` : ''}</dd>
{#if cert.subjectAltName}<dt>Alt names</dt><dd>{cert.subjectAltName}</dd>{/if}
<dt>Serial</dt><dd class="mono">{cert.serialNumber}</dd>
<dt>SHA-1</dt><dd class="mono">{cert.fingerprint}</dd>
<dt>SHA-256</dt><dd class="mono">{cert.fingerprint256}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn" onclick={copyPem}>Copy PEM</button>
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
.status { margin: 8px 0 0; padding: 8px 10px; border-left: 3px solid var(--warn); background: var(--bg-0); font-size: 14px; }
.status.ok { border-color: var(--speaking); }
.tabs { display: flex; gap: 2px; margin-top: 12px; overflow-x: auto; }
.tabs button { padding: 6px 12px; background: var(--bg-3); color: var(--text-dim); white-space: nowrap; font-size: 13px; }
.tabs button.active { background: var(--accent); color: var(--accent-text); }
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
.tag { text-transform: none; letter-spacing: 0; font-weight: 600; padding: 0 6px; border: 1px solid var(--warn); color: var(--warn); }
.tag.bad { border-color: var(--danger); color: var(--danger); }
dl { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
const users = $derived(session.usersIn(channel.id));
const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === channel.id);
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// 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;
function clickOrDouble(single: () => void, double: () => void) {
return {
onclick: () => { clearTimeout(timer); timer = setTimeout(single, 220); },
ondblclick: () => { clearTimeout(timer); double(); }
};
}
const channelClicks = clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); }
);
</script>
<li>
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
</button>
{:else}
<span class="caret"></span>
{/if}
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
<Icon name={channel.canEnter ? 'channel' : 'lock'} size={16} />
<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>
</div>
{#if users.length && expanded}
<ul class="users">
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble(
() => { session.showUser(u.session); ui.panelOpen = true; },
() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }
)}
<li>
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
{#if u.deaf || u.selfDeaf}
<span class="state" class:admin={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="state" class:admin={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if children.length && expanded}
<ul>
{#each children as c (c.id)}
<ChannelNode channel={c} depth={depth + 1} {onnavigate} />
{/each}
</ul>
{/if}
</li>
<style>
ul { list-style: none; margin: 0; padding: 0; }
li { margin: 0; }
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); }
.channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
.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; }
.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; }
.user.self .label { color: var(--text); }
.label.talking { color: var(--speaking); }
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
.state { color: var(--text-faint); display: inline-flex; }
.state.admin { color: var(--danger); }
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import Icon from './Icon.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
const isDm = $derived(session.view.startsWith('dm:'));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return '';
return (session.channel(Number(session.view.slice(3)))?.description ?? '').replace(/<[^>]*>/g, ' ').trim();
});
</script>
<section class="chat" aria-label="Chat">
<header>
{#if showMenu}<button class="icon-btn" onclick={onmenu} aria-label="Open channels"><Icon name="menu" /></button>{/if}
<Icon name={isDm ? 'message' : 'hash'} size={20} />
<h1>{title}</h1>
{#if topic}<span class="topic" title={topic}>{topic}</span>{/if}
<span class="grow"></span>
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header>
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
{#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
{:else if session.status === 'connecting'}
<h2>Connecting...</h2>
{:else}
<h2>mumh5</h2>
<p>Choose a server to get started.</p>
{/if}
</div>
{/snippet}
</ChatLog>
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
</section>
<style>
.chat { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h1 { flex: 0 1 auto; max-width: 60%; font-size: 16px; margin: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topic { flex: 1 1 0; font-size: 13px; padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.grow { flex: 1; }
.topic + .grow { flex: 0; }
/* On phones the channel name matters more than its description */
@media (max-width: 600px) { .topic { display: none; } h1 { max-width: none; } .topic + .grow { flex: 1; } }
.empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; }
.empty p { margin: 0; color: var(--text-dim); }
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Icon from './Icon.svelte';
import Message from './Message.svelte';
import { session } from '../lib/session.svelte.ts';
// Message list for one chat (channel or DM), with drag and drop upload into that chat
let { key, empty }: { key: string; empty: Snippet } = $props();
const entries = $derived(session.logs[key] ?? []);
const title = $derived(session.viewTitle(key));
let list: HTMLDivElement;
let pinned = true; // stick to the bottom unless the user scrolled up
let dragging = $state(0);
$effect(() => {
void entries.length;
void key;
if (pinned) tick().then(() => list && (list.scrollTop = list.scrollHeight));
});
// Media loads after insertion and grows the list; keep the bottom pinned
function onload() { if (pinned) list.scrollTop = list.scrollHeight; }
function ondrop(e: DragEvent) {
e.preventDefault();
dragging = 0;
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && session.status === 'connected') session.uploadFiles(files, key);
}
const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
};
</script>
<div class="wrap" role="log"
ondragenter={e => { if (e.dataTransfer?.types.includes('Files')) dragging++; }}
ondragleave={() => (dragging = Math.max(0, dragging - 1))}
ondragover={e => e.preventDefault()} {ondrop}>
<div class="list" bind:this={list} onscroll={() => (pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 40)}
onloadcapture={onload} onloadedmetadatacapture={onload}>
{#if !entries.length}{@render empty()}{/if}
{#each entries as entry, i (entry.id)}
<Message {entry} grouped={grouped(i)} />
{/each}
<div class="end"></div>
</div>
{#if dragging}
<div class="drop"><div><Icon name="download" size={40} /><p>Drop to upload to {key.startsWith('dm:') ? '@' : '#'}{title}</p></div></div>
{/if}
</div>
<style>
.wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.list { flex: 1; overflow-y: auto; overflow-x: hidden; padding-bottom: 12px; }
.end { height: 4px; }
.drop { position: absolute; inset: 0; z-index: 10; background: rgb(13 14 16 / 0.8); display: flex; align-items: center; justify-content: center; padding: 16px; pointer-events: none; }
.drop div { border: 2px dashed var(--accent); padding: 32px 48px; text-align: center; color: var(--text); }
.drop p { margin: 8px 0 0; font-weight: 600; }
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { acceptAttr } from '../lib/upload.ts';
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
let text = $state('');
let input: HTMLTextAreaElement;
let picker: HTMLInputElement;
const disabled = $derived(session.status !== 'connected');
const uploads = $derived(session.uploads.filter(u => u.key === target));
// Know the host's allowed types before the picker opens
$effect(() => {
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
});
function submit() {
if (session.sendText(text, target)) text = '';
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
function onpaste(e: ClipboardEvent) {
const files = [...(e.clipboardData?.files ?? [])];
if (files.length) {
e.preventDefault();
session.uploadFiles(files, target);
}
}
// Grow with content up to a limit
$effect(() => {
void text;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
});
$effect(() => {
void target;
if (autofocus && !disabled && matchMedia('(pointer: fine)').matches) input.focus();
});
</script>
<div class="composer">
{#if uploads.length}
<ul class="uploads">
{#each uploads as u (u.id)}
<li class:error={u.error}>
<Icon name="file" size={16} />
<span class="name">{u.name}</span>
{#if u.error}<span>{u.error}</span>{:else}<span class="bar"><span style:width="{u.progress * 100}%"></span></span>{/if}
</li>
{/each}
</ul>
{/if}
<div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste}
aria-label="Message"></textarea>
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button>
</div>
</div>
<style>
.composer { flex: none; padding: 0 16px 16px; }
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; }
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; }
.send:not(:disabled) { color: var(--accent-hover); }
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
.uploads li.error { color: var(--danger); border-color: var(--danger); }
.name { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar { flex: 1; height: 4px; background: var(--bg-3); }
.bar span { display: block; height: 100%; background: var(--accent); transition: width 120ms; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<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';
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
const initial = server;
let label = $state(initial?.label ?? '');
let host = $state(initial?.host ?? '');
let port = $state(initial?.port ?? 64738);
let username = $state(initial?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
let icon = $state(initial?.icon ?? '');
let iconError = $state('');
let iconPicker: HTMLInputElement;
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}>
<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>
<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}>
<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; }
.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>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { menu } from '../lib/menu.svelte.ts';
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
// Keep the menu on screen near the pointer
$effect(() => {
const m = menu.open;
if (!m || !el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
};
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
function onkeydown(e: KeyboardEvent) {
if (!menu.open) return;
if (e.key === 'Escape') menu.close();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const buttons = [...el!.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
buttons[(i + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
}
}
</script>
<svelte:window {onkeydown} onblur={() => menu.close()} onresize={() => menu.close()} />
{#if menu.open}
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#each menu.open.items as item, i (i)}
{#if item.separator}
<div class="sep" role="separator"></div>
{:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}>
<span class="label">{item.label}</span>
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
</button>
{/if}
{/each}
</div>
{/if}
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.menu {
position: fixed; z-index: 201; min-width: 200px; max-width: 280px; padding: 6px;
background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
}
button { display: flex; align-items: center; gap: 12px; width: 100%; padding: 7px 10px; font-size: 14px; text-align: left; color: var(--text); }
button:hover:not(:disabled), button:focus-visible { background: var(--accent); color: var(--accent-text); outline: none; }
button.danger { color: var(--danger); }
button.danger:hover:not(:disabled), button.danger:focus-visible { background: var(--danger); color: #fff; }
.label { flex: 1; }
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
</style>
+10
View File
@@ -0,0 +1,10 @@
<script lang="ts">
import { ICON_PATHS as paths } from '../lib/icons.ts';
let { name, size = 20 }: { name: string; size?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="square" stroke-linejoin="miter" aria-hidden="true">
<path d={paths[name] ?? ''} />
</svg>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import { native, 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';
let message = $state('');
const usedBy = (id: string) => store.servers.filter(s => s.identityId === id).map(s => s.label);
async function act(task: () => Promise<unknown>) {
message = '';
try {
await task();
await identities.refresh();
} catch (e) {
message = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
}
function exportId(i: IdentitySummary) {
ui.prompt = {
title: `Back up ${i.name}`, label: 'Password for the backup file', confirm: '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);
if (path) message = `Saved to ${path}`;
})
};
}
function rename(i: IdentitySummary) {
ui.prompt = { title: 'Rename identity', label: 'Name', confirm: 'Rename', initial: i.name, onsubmit: n => act(() => native!.identities.rename(i.id, n)) };
}
function remove(i: IdentitySummary) {
const servers = usedBy(i.id);
ui.prompt = {
title: `Delete ${i.name}?`, label: '', confirm: 'Delete', danger: true, input: 'none',
text: `Registrations on servers are tied to this certificate and cannot be recovered without a backup.${servers.length ? ` Used by: ${servers.join(', ')}; they will use the default identity instead.` : ''}`,
onsubmit: () => act(() => native!.identities.remove(i.id))
};
}
</script>
<ul class="ids">
{#each identities.list as i (i.id)}
<li class:default={i.isDefault}>
<div class="head">
<strong>{i.name}</strong>
{#if i.isDefault}<span class="tag">default</span>{/if}
</div>
<code>{i.fingerprint}</code>
{#if usedBy(i.id).length}<p class="help">Used for {usedBy(i.id).join(', ')}</p>{/if}
<div class="actions">
{#if !i.isDefault}<button class="link" onclick={() => act(() => native!.identities.setDefault(i.id))}>Make default</button>{/if}
<button class="link" onclick={() => (ui.cert = { title: `Certificate: ${i.name}`, chain: [i.cert] })}>View certificate</button>
<button class="link" onclick={() => exportId(i)}>Back up</button>
<button class="link" onclick={() => rename(i)}>Rename</button>
<button class="link danger" onclick={() => remove(i)}>Delete</button>
</div>
</li>
{:else}
<li class="help">No identity yet; one is created when you first connect.</li>
{/each}
</ul>
<button class="btn" onclick={() => (ui.wizard = 'add')}>Add identity</button>
{#if message}<p class="help msg">{message}</p>{/if}
<style>
.ids { list-style: none; margin: 8px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
li { padding: 10px 12px; background: var(--bg-0); border: 1px solid var(--line); }
li.default { border-color: var(--accent); }
.head { display: flex; align-items: center; gap: 8px; }
.tag { font-size: 11px; font-weight: 600; padding: 0 6px; background: var(--accent); color: var(--accent-text); }
code { display: block; margin-top: 4px; font: 11px var(--mono); color: var(--text-dim); overflow-wrap: anywhere; }
.actions { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; }
.link { color: var(--link); font-size: 13px; }
.link:hover { text-decoration: underline; }
.link.danger { color: var(--danger); }
.help { margin: 4px 0 0; font-size: 13px; color: var(--text-dim); }
.msg { overflow-wrap: anywhere; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type IdentitySummary } from '../lib/native.ts';
import { identities } from '../lib/identities.svelte.ts';
// setup: first start (marks setup as done at the end); add: adding another identity from Settings
let { mode = 'setup', onclose }: { mode?: 'setup' | 'add'; onclose: () => void } = $props();
type Step = 'welcome' | 'create' | 'import' | 'backup' | 'done';
let step = $state<Step>('welcome');
let busy = $state(false);
let error = $state('');
let result = $state<IdentitySummary | null>(null);
let mumble = $state<{ name: string } | null>(null);
// create
let name = $state('');
let email = $state('');
// import
let file = $state<File | null>(null);
let filePassword = $state('');
// backup
let backupPassword = $state('');
let backupConfirm = $state('');
let savedTo = $state('');
// svelte-ignore state_referenced_locally
const existing = mode === 'setup' ? identities.default : undefined;
native?.identities.findMumble().then(m => (mumble = m));
async function run(task: () => Promise<IdentitySummary>, next: Step) {
busy = true;
error = '';
try {
result = await task();
await identities.refresh();
step = next;
} catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
} finally {
busy = false;
}
}
const create = (e: SubmitEvent) => {
e.preventDefault();
run(() => native!.identities.create(name.trim(), email.trim()), 'backup');
};
const importFile = async (e: SubmitEvent) => {
e.preventDefault();
if (!file) return;
const bytes = new Uint8Array(await file.arrayBuffer());
run(() => native!.identities.importP12(bytes, filePassword, ''), 'done');
};
const importMumble = () => run(() => native!.identities.importMumble(''), 'done');
function keepExisting() {
result = existing ?? null;
step = 'backup';
}
async function saveBackup() {
if (!result) return;
busy = true;
error = '';
try {
const path = await native!.identities.exportP12(result.id, backupPassword);
if (path) {
savedTo = path;
step = 'done';
}
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function finish() {
if (mode === 'setup') await native?.identities.finishSetup();
await identities.refresh();
onclose();
}
const title = $derived(mode === 'setup' ? 'Set up your identity' : 'Add an identity');
const passwordsOk = $derived(backupPassword.length >= 8 && backupPassword === backupConfirm);
</script>
<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>
<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>
</button>
{#if mumble}
<button class="choice" disabled={busy} onclick={importMumble}>
<Icon name="download" /><span><strong>Use my Mumble desktop identity</strong><small>Found "{mumble.name}" from the desktop client; keeps your registrations</small></span>
</button>
{/if}
<button class="choice" onclick={() => (step = 'import')}>
<Icon name="file" /><span><strong>Import a certificate file</strong><small>A .p12 or .pfx backup, for example exported from Mumble</small></span>
</button>
{#if existing}
<button class="choice" onclick={keepExisting}>
<Icon name="shield" /><span><strong>Keep the current identity</strong><small>"{existing.name}", created automatically earlier</small></span>
</button>
{/if}
</div>
{:else if step === 'create'}
<form id="w-create" onsubmit={create}>
<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" />
<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>
{:else if step === 'import'}
<form id="w-import" onsubmit={importFile}>
<label for="w-file">Certificate file</label>
<input id="w-file" type="file" accept=".p12,.pfx,application/x-pkcs12" onchange={e => (file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null)} />
<label for="w-fpass">Password <span class="opt">(leave empty if it has none)</span></label>
<input id="w-fpass" type="password" bind:value={filePassword} autocomplete="off" />
</form>
{: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>
<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>
<input id="w-bconf" type="password" bind:value={backupConfirm} autocomplete="new-password" />
{:else if step === 'done'}
<p>You are set up{result ? ` as ${result.name}` : ''}.</p>
{#if result}<p class="mono">{result.fingerprint}</p>{/if}
{#if savedTo}<p class="ok">Backup saved to {savedTo}</p>{/if}
<p class="dim">Manage identities, pick one per server and export backups in Settings.</p>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
{#if step === 'welcome'}
{#if mode === 'add'}<button class="btn" onclick={onclose}>Cancel</button>{/if}
{:else if step === 'create'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-create" disabled={busy || !name.trim()}>{busy ? 'Generating...' : 'Create'}</button>
{:else if step === 'import'}
<button class="btn" onclick={() => (step = 'welcome')}>Back</button>
<button class="btn primary" type="submit" form="w-import" disabled={busy || !file}>Import</button>
{:else if step === 'backup'}
<button class="btn" onclick={() => (step = 'done')}>Skip for now</button>
<button class="btn primary" disabled={busy || !passwordsOk} onclick={saveBackup}>Save backup...</button>
{:else}
<button class="btn primary" onclick={finish}>Done</button>
{/if}
{/snippet}
</Modal>
<style>
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; }
.choice { display: flex; align-items: center; gap: 14px; padding: 12px 14px; text-align: left; background: var(--bg-3); border: 1px solid var(--line); }
.choice:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-hover); }
.choice span { display: flex; flex-direction: column; }
.choice small { color: var(--text-dim); font-size: 13px; }
.opt { text-transform: none; font-weight: 400; letter-spacing: 0; color: var(--text-faint); }
.mono { font: 12px var(--mono); overflow-wrap: anywhere; color: var(--text); }
.ok { color: var(--speaking); overflow-wrap: anywhere; }
.dim { font-size: 13px; }
.error { color: var(--danger); }
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import Icon from './Icon.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const here = $derived(session.self ? session.usersIn(session.self.channelId) : []);
const elsewhere = $derived(session.allUsers().filter(u => u.channelId !== session.self?.channelId));
</script>
<aside class="members" aria-label="Members">
{#snippet group(title: string, users: typeof here)}
{#if users.length}
<h3>{title} ({users.length})</h3>
<ul>
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
<li>
<button onclick={() => session.showUser(u.session)} title={isSelf ? 'You' : `${u.name} (double-click to message)`}
ondblclick={() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={30} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
<span class="who">
<span class="name">{store.displayName(u)}</span>
<span class="sub">{u.userId != null ? 'Registered' : 'Guest'}{title !== 'In channel' ? ` in ${session.channel(u.channelId)?.name || 'Root'}` : ''}</span>
</span>
{#if u.selfDeaf || u.deaf}<Icon name="headphones-off" size={14} />{:else if u.selfMute || u.mute}<Icon name="mic-off" size={14} />{/if}
</button>
</li>
{/each}
</ul>
{/if}
{/snippet}
{@render group('In channel', here)}
{@render group('Elsewhere', elsewhere)}
</aside>
<style>
.members { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 6px; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 14px 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 5px 8px; text-align: left; color: var(--text-dim); }
button:hover { background: var(--bg-hover); color: var(--text); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sub { font-size: 11px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import YouTubeEmbed from './YouTubeEmbed.svelte';
import { store } from '../lib/settings.svelte.ts';
import type { ChatEntry } from '../lib/session.svelte.ts';
let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props();
const time = $derived(new Date(entry.time));
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
</script>
{#if entry.kind === 'system'}
<div class="system">
<span class="mark" aria-hidden="true"></span>
<div class="html">{@html entry.html} <span class="time">{hhmm}</span></div>
</div>
{:else}
<article class="msg" class:grouped>
<div class="gutter">
{#if grouped}
<span class="time hover">{hhmm}</span>
{:else}
<Avatar name={entry.author} size={38} />
{/if}
</div>
<div class="content">
{#if !grouped}
<header>
<strong class:self={entry.self}>{entry.author}</strong>
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
</header>
{/if}
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
{#each entry.embeds as m (m.url)}
{#if m.kind === 'youtube'}
{#if store.settings.youtube !== 'off'}<div class="embed"><YouTubeEmbed videoId={m.videoId!} start={m.start} url={m.url} /></div>{/if}
{:else}
<div class="embed">
{#if m.kind === 'image'}
<a href={m.url} target="_blank" rel="noopener noreferrer"><img src={m.url} alt={m.name} loading="lazy" /></a>
{:else if m.kind === 'video'}
<video src={m.url} controls preload="metadata"><track kind="captions" /></video>
{:else}
<audio src={m.url} controls preload="metadata"></audio>
{/if}
</div>
{/if}
{/each}
</div>
</article>
{/if}
<style>
.msg { display: flex; gap: 14px; padding: 2px 16px; margin-top: 14px; }
.msg.grouped { margin-top: 0; }
.msg:hover { background: rgb(255 255 255 / 0.02); }
.gutter { width: 38px; flex: none; display: flex; justify-content: center; }
.content { flex: 1; min-width: 0; }
header { display: flex; align-items: baseline; gap: 8px; }
header strong { font-weight: 600; }
header strong.self { color: var(--accent-hover); }
time, .time { font-size: 11px; color: var(--text-faint); }
.time.hover { visibility: hidden; line-height: 22px; }
.msg:hover .time.hover { visibility: visible; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(p) { margin: 0; }
.html :global(img) { max-width: min(520px, 100%); max-height: 360px; display: block; margin-top: 4px; border: 1px solid var(--line); }
.html :global(pre), .html :global(code) { font-family: var(--mono); font-size: 13px; background: var(--bg-0); padding: 1px 4px; }
.html :global(pre) { padding: 8px 10px; overflow-x: auto; }
.html :global(blockquote) { margin: 4px 0; padding-left: 10px; border-left: 3px solid var(--line); color: var(--text-dim); }
.embed { margin-top: 6px; max-width: min(520px, 100%); }
.embed img, .embed video { display: block; max-width: 100%; max-height: 360px; background: var(--bg-0); border: 1px solid var(--line); }
.embed audio { width: 100%; }
.system { display: flex; gap: 14px; padding: 4px 16px; margin-top: 8px; color: var(--text-dim); font-size: 13px; }
.system .mark { width: 38px; flex: none; display: flex; justify-content: center; padding-top: 7px; }
.system .mark::before { content: ''; width: 6px; height: 6px; background: var(--text-faint); }
.system .time { margin-left: 6px; }
.system .html { flex: 1; min-width: 0; }
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
let { title, onclose, children, footer, width = 460 }:
{ title: string; onclose: () => void; children: Snippet; footer?: Snippet; width?: number } = $props();
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') onclose(); }} />
<div class="backdrop" role="presentation" onmousedown={e => { if (e.target === e.currentTarget) onclose(); }}>
<div class="modal" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}px">
<header>
<h2>{title}</h2>
<button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" /></button>
</header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 100; background: rgb(0 0 0 / 0.6);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal {
width: 100%; max-height: calc(100vh - 32px); display: flex; flex-direction: column;
background: var(--bg-1); border: 1px solid var(--line); box-shadow: 0 16px 48px rgb(0 0 0 / 0.5);
}
header { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 0 20px; }
h2 { margin: 0; font-size: 18px; }
.body { padding: 4px 20px 20px; overflow-y: auto; }
footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; background: var(--bg-0); border-top: 1px solid var(--line); }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import Modal from './Modal.svelte';
let { title, label, confirm, danger = false, input = 'text', initial = '', text = '', onsubmit, onclose }:
{ title: string; label: string; confirm: string; danger?: boolean; input?: 'text' | 'password' | 'none'; initial?: string;
text?: string; onsubmit: (value: string) => void; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
let value = $state(initial);
</script>
<Modal {title} {onclose}>
<form id="prompt" onsubmit={e => { e.preventDefault(); onsubmit(value); onclose(); }}>
{#if text}<p>{text}</p>{/if}
{#if input !== 'none'}
<label for="p-val">{label}</label>
<!-- svelte-ignore a11y_autofocus -->
<input id="p-val" type={input} bind:value autofocus autocomplete="off" />
{/if}
</form>
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn" class:primary={!danger} class:danger type="submit" form="prompt">{confirm}</button>
{/snippet}
</Modal>
<style>
p { color: var(--text-dim); }
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
const q = $derived(ui.quickAudio!);
const s = voice.settings;
let el: HTMLDivElement | undefined = $state();
let pos = $state({ x: 0, y: 0 });
voice.refreshDevices();
// Open above the button it came from, kept on screen
$effect(() => {
if (!el) return;
const r = el.getBoundingClientRect();
pos = {
x: Math.max(8, Math.min(q.x - r.width / 2, window.innerWidth - r.width - 8)),
y: Math.max(8, q.y - r.height - 8)
};
});
const close = () => (ui.quickAudio = null);
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const modes: { id: TransmitMode; label: string }[] = [
{ id: 'vad', label: 'Activity' }, { id: 'ptt', label: 'Push to talk' }, { id: 'continuous', label: 'Always on' }
];
</script>
<svelte:window onkeydown={e => { if (e.key === 'Escape') close(); }} onresize={close} />
<div class="catcher" role="presentation" onmousedown={close} oncontextmenu={e => { e.preventDefault(); close(); }}></div>
<div class="pop" role="dialog" aria-label={q.kind === 'input' ? 'Microphone' : 'Output'} bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#if q.kind === 'input'}
<label for="qa-in">Microphone</label>
<select id="qa-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-gain">Input volume: {Math.round(s.inputGain * 100)}%</label>
<input id="qa-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
<div class="meter" aria-hidden="true"><div class="fill" class:open={s.mode !== 'vad' || voice.level > s.vadThreshold} style:width="{pct(voice.level)}%"></div></div>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>{m.label}</button>
{/each}
</div>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
{:else}
<label for="qa-out">Output device</label>
<select id="qa-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
<label for="qa-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="qa-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
{/if}
<button class="more" onclick={() => { close(); ui.settingsTab = 'voice'; ui.settingsOpen = true; }}>Voice settings...</button>
</div>
<style>
.catcher { position: fixed; inset: 0; z-index: 200; }
.pop { position: fixed; z-index: 201; width: 260px; padding: 10px 12px 8px; background: var(--bg-0); border: 1px solid var(--line); box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); }
label { margin-top: 10px; }
label:first-child { margin-top: 0; }
select { font-size: 13px; padding: 6px 8px; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.meter { height: 6px; background: var(--bg-3); margin-top: 4px; overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.fill.open { background: var(--speaking); }
.modes { display: flex; gap: 2px; margin-top: 10px; }
.modes button { flex: 1; padding: 5px 4px; font-size: 12px; background: var(--bg-3); color: var(--text-dim); }
.modes button.active { background: var(--accent); color: var(--accent-text); }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 13px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
.more { display: block; width: 100%; margin-top: 10px; padding: 6px 0 0; border-top: 1px solid var(--line); text-align: left; font-size: 13px; color: var(--link); }
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts';
// Rich text in the subset Mumble clients render (Qt rich text): bold, italic, underline,
// strikethrough, colors, lists, links and inline data: images. `limit` is the server's
// size limit for the whole HTML, pictures included.
let { value = '', limit, placeholder = '', onchange }:
{ value?: string; limit: number; placeholder?: string; onchange: (html: string) => void } = $props();
let editor: HTMLDivElement;
let picker: HTMLInputElement;
let source = $state(false);
let sourceText = $state('');
let html = $state('');
let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit);
onMount(() => {
document.execCommand('defaultParagraphSeparator', false, 'p');
document.execCommand('styleWithCSS', false, 'true');
editor.innerHTML = toMumbleHtml(value) || '<p><br></p>';
sync();
});
function sync() {
html = toMumbleHtml(editor.innerHTML);
onchange(html);
}
function exec(cmd: string, arg?: string) {
editor.focus();
document.execCommand(cmd, false, arg);
sync();
}
// window.prompt does not exist in Electron; use the app's dialog and restore the selection after
function link() {
const sel = window.getSelection();
const range = sel && sel.rangeCount && editor.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
ui.prompt = {
title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
onsubmit: raw => {
const url = raw.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
editor.focus();
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
if (range && !range.collapsed) exec('createLink', url);
else exec('insertHTML', `<a href="${url.replace(/"/g, '&quot;')}">${url.replace(/</g, '&lt;')}</a>`);
}
};
}
async function insertImage(file: File) {
error = '';
// Room left for the picture inside the server's limit
const room = (limit || 131072) - size - 64;
const uri = await makeInlineImage(file, room, [640, 480, 320, 200, 120]);
if (!uri) {
error = room < 2000 ? 'No room left for a picture within the server limit.' : 'Could not make this picture small enough.';
return;
}
exec('insertImage', uri);
}
function onpaste(e: ClipboardEvent) {
const img = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
e.preventDefault();
if (img) return insertImage(img);
const pasted = e.clipboardData?.getData('text/html');
if (pasted) exec('insertHTML', toMumbleHtml(pasted));
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
}
function toggleSource() {
if (!source) {
sourceText = html;
source = true;
} else {
editor.innerHTML = toMumbleHtml(sourceText) || '<p><br></p>';
source = false;
sync();
}
}
function onkeydown(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey)) return;
const map: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
const cmd = map[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); exec(cmd); }
}
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
{#if source}
<textarea class="area src" bind:value={sourceText} oninput={() => onchange(toMumbleHtml(sourceText))} aria-label="HTML source" spellcheck="false"></textarea>
{/if}
<div class="foot">
{#if error}<span class="err">{error}</span>{/if}
<span class="grow"></span>
<span class="count" title="Size of the description as the server counts it">{formatBytes(source ? sourceText.length : size)}{limit ? ` / ${formatBytes(limit)}` : ''}</span>
</div>
</div>
<style>
.rich { border: 1px solid var(--line); background: var(--bg-0); }
.rich:focus-within { border-color: var(--accent); }
.rich.over { border-color: var(--danger); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
.area.hidden { display: none; }
.area :global(p) { margin: 0 0 6px; }
.area :global(img) { max-width: 100%; }
.area.blank::before { content: attr(data-placeholder); color: var(--text-faint); position: absolute; left: 10px; top: 8px; pointer-events: none; }
.src { width: 100%; border: 0; resize: vertical; font: 12px var(--mono); background: transparent; }
.foot { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-top: 1px solid var(--line); font-size: 12px; color: var(--text-faint); }
.over .count { color: var(--danger); font-weight: 600; }
.err { color: var(--danger); }
</style>
+239
View File
@@ -0,0 +1,239 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import Members from './Members.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu, changeNamePrompt, nicknamePrompt } from '../lib/actions.ts';
import { voice, userKey } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte';
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
const panel = $derived(session.panel);
const channel = $derived(panel.kind === 'channel' ? session.channel(panel.id) : undefined);
const user = $derived(panel.kind === 'user' ? session.user(panel.session) : undefined);
const isSelf = $derived(!!user && user.session === session.self?.session);
// Description or comment arrive later when they were only announced by hash
// Re-runs when the hash changes, e.g. right after saving a long description
$effect(() => { if (channel?.descriptionHash && !channel.description) session.loadDescription(channel.id); });
$effect(() => { if (user?.commentHash && !user.comment) session.loadComment(user.session); });
const description = $derived(channel?.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
const comment = $derived(user?.comment ? renderIncoming(user.comment, store.trustedHosts).html : '');
const pendingDescription = $derived(!!channel?.descriptionHash && !channel.description);
const pendingComment = $derived(!!user?.commentHash && !user.comment);
// User-resizable width, remembered between sessions
const MIN_W = 240, DEFAULT_W = 300;
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(MIN_W, Math.min(maxW(), w)));
try { localStorage.setItem('mumh5.panelWidth', String(width)); } catch { /* storage unavailable */ }
}
function startResize(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
const right = (handle.parentElement as HTMLElement).getBoundingClientRect().right;
const move = (ev: PointerEvent) => setWidth(right - ev.clientX);
const up = () => {
resizing = false;
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function resizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') setWidth(width + 16);
else if (e.key === 'ArrowRight') setWidth(width - 16);
else return;
e.preventDefault();
}
let editing = $state(false);
let draft = $state('');
$effect(() => { void panel; editing = false; });
function startEdit() {
draft = user?.comment ?? '';
editing = true;
}
function saveComment() {
session.setComment(draft);
editing = false;
}
</script>
<svelte:window onresize={() => setWidth(width)} />
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
{#if panel.kind === 'members' || (!channel && !user)}
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
<Members {onnavigate} />
{:else if channel && panel.kind === 'channel'}
<header>
<Icon name="channel" size={18} />
<h2>{channel.name || 'Root'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={panel.tab === 'info'} class:active={panel.tab === 'info'} onclick={() => session.showChannel(channel.id, 'info')}>Info</button>
<button role="tab" aria-selected={panel.tab === 'chat'} class:active={panel.tab === 'chat'} onclick={() => session.showChannel(channel.id, 'chat')}>
Chat{#if session.unread[`ch:${channel.id}`]}<span class="badge">{session.unread[`ch:${channel.id}`]}</span>{/if}
</button>
</div>
{#if panel.tab === 'info'}
<div class="body">
{#if description}
<div class="html">{@html description}</div>
{:else if pendingDescription}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">This channel has no description.</p>
{/if}
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
<div class="actions">
{#if session.self?.channelId === channel.id}
<span class="here">You are in this channel</span>
{:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
{channel.canEnter ? 'Join channel' : 'No access'}
</button>
{/if}
<button class="btn" onclick={() => session.showChannel(channel.id, 'chat')}>Open chat</button>
</div>
</div>
{:else}
<ChatLog key={`ch:${channel.id}`}>
{#snippet empty()}<p class="dim pad">Messages sent here go to everyone in #{channel.name || 'Root'}, even if you are not in it.</p>{/snippet}
</ChatLog>
<Composer target={`ch:${channel.id}`} placeholder={`Message #${channel.name || 'Root'}`} />
{/if}
{:else if user}
<header>
<h2>{isSelf ? 'Your profile' : 'Profile'}</h2>
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
</header>
<div class="body">
<div class="who" role="presentation" oncontextmenu={e => menu.show(e, userMenu(user))}>
<Avatar name={user.name} size={56} speaking={voice.talking[user.session] || (isSelf && voice.transmitting)} />
<div>
<h3>{store.displayName(user)}</h3>
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
<p class="badges">
<span class="badge-reg" class:registered={user.userId != null}>{user.userId != null ? 'Registered' : 'Guest'}</span>
{#if isSelf && user.userId == null}
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
{/if}
<span class="dim small">in {session.channel(user.channelId)?.name || 'Root'}</span>
</p>
<p class="flags">
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
{#if user.prioritySpeaker}<span>priority speaker</span>{/if}
{#if user.recording}<span class="bad">recording</span>{/if}
</p>
</div>
</div>
{#if isSelf}
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
{:else}
<h4>Voice</h4>
<label for="p-vol" class="vol">Volume for me: {Math.round((voice.settings.userVolumes[userKey(user)] ?? 1) * 100)}%</label>
<input id="p-vol" type="range" min="0" max="3" step="0.05" value={voice.settings.userVolumes[userKey(user)] ?? 1}
oninput={e => voice.setUserVolume(user, Number(e.currentTarget.value))} />
<div class="actions">
<button class="btn" class:danger={!!voice.settings.localMutes[userKey(user)]} onclick={() => voice.setLocalMute(user, !voice.settings.localMutes[userKey(user)])}>
{voice.settings.localMutes[userKey(user)] ? 'Unmute for me' : 'Mute for me'}
</button>
<button class="btn" onclick={() => nicknamePrompt(user)}>Set nickname</button>
</div>
{/if}
<h4>Description</h4>
{#if editing}
<RichEditor value={draft} limit={session.htmlLimit} placeholder="Tell people about yourself" onchange={h => (draft = h)} />
<div class="actions">
<button class="btn primary" onclick={saveComment}>Save</button>
<button class="btn" onclick={() => (editing = false)}>Cancel</button>
</div>
{:else}
{#if comment}
<div class="html">{@html comment}</div>
{:else if pendingComment}
<p class="dim">Loading description...</p>
{:else}
<p class="dim">{isSelf ? 'You have no description yet.' : 'No description.'}</p>
{/if}
{#if isSelf}<div class="actions"><button class="btn" onclick={startEdit}>Edit description</button></div>{/if}
{/if}
{#if !isSelf}
<div class="actions">
<button class="btn primary" onclick={() => { session.selectView(`dm:${user.session}`); onnavigate(); }}>Send message</button>
<button class="btn" onclick={e => menu.show(e, userMenu(user))}>More</button>
</div>
{/if}
</div>
{/if}
</aside>
<style>
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
.panel.resizing { user-select: none; }
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
.tabs button { flex: 1; padding: 10px; color: var(--text-dim); font-weight: 600; font-size: 14px; display: inline-flex; justify-content: center; gap: 6px; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.badge { background: var(--danger); color: #fff; font-size: 11px; padding: 0 5px; line-height: 16px; }
.body { flex: 1; overflow-y: auto; padding: 14px; }
.pad { padding: 14px; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 13px; margin-top: 8px; }
.html { overflow-wrap: anywhere; user-select: text; }
.html :global(img) { max-width: 100%; }
.html :global(p) { margin: 0 0 6px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
.here { color: var(--speaking); font-weight: 600; font-size: 14px; }
.who { display: flex; gap: 14px; align-items: center; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.badges { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; }
.badges .small { margin: 0; }
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.register { font-size: 12px; color: var(--link); }
.register:hover { text-decoration: underline; }
.vol { margin-top: 0; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
.flags .bad { border-color: var(--danger); color: var(--danger); }
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props();
const initials = (label: string) =>
label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
const unreadOf = (id: string) => {
const s = sessions.byServer(id);
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
};
// Drag and drop reordering
let dragging = $state<string | null>(null);
let dropIndex = $state<number | null>(null);
function onDragOver(e: DragEvent, i: number) {
if (!dragging) return;
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
dropIndex = e.clientY < r.top + r.height / 2 ? i : i + 1;
}
function onDrop(e: DragEvent) {
e.preventDefault();
if (dragging && dropIndex !== null) {
const from = store.servers.findIndex(s => s.id === dragging);
store.moveServer(dragging, dropIndex > from ? dropIndex - 1 : dropIndex);
}
dragging = null;
dropIndex = null;
}
// Icon picked from the tile's context menu
let iconPicker: HTMLInputElement;
let iconFor = '';
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
}
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
menu.show(e, [
state === 'idle'
? { label: 'Connect', action: () => sessions.open(s) }
: { label: 'Disconnect', action: () => sessions.close(s.id) },
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
{ separator: true, label: '' },
{ label: 'Move up', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
{ label: 'Move down', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
{ separator: true, label: '' },
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
{ label: 'Edit...', action: () => onedit(s) }
]);
}
</script>
<nav class="rail" aria-label="Servers" ondrop={onDrop} ondragover={e => { if (dragging) e.preventDefault(); }}>
{#each store.servers as s, i (s.id)}
{@const state = stateOf(s.id)}
{@const unread = unreadOf(s.id)}
{#if dropIndex === i && dragging}<div class="drop-line"></div>{/if}
<button class="tile" class:active={sessions.activeId === s.id} class:live={state === 'connected'}
class:connecting={state === 'connecting'} class:voice={sessions.voiceId === s.id && state === 'connected'}
class:dragged={dragging === s.id}
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
draggable="true"
ondragstart={e => { dragging = s.id; e.dataTransfer?.setData('text/plain', s.id); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; }}
ondragend={() => { dragging = null; dropIndex = null; }}
ondragover={e => onDragOver(e, i)}
onclick={() => sessions.open(s)}
oncontextmenu={e => serverMenu(e, s, i)}>
{#if s.icon}<img class="icon" src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}
{#if unread}<span class="unread">{unread > 99 ? '99+' : unread}</span>{/if}
</button>
{/each}
{#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>
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
<style>
.rail {
width: 68px; flex: none; background: var(--bg-0); display: flex; flex-direction: column;
align-items: center; gap: 8px; padding: 12px 0; overflow-y: auto;
}
.tile {
position: relative; width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
background: var(--bg-3); font-weight: 700; font-size: 15px; transition: background 100ms, color 100ms;
}
.tile:hover { background: var(--accent); color: var(--accent-text); }
.tile.active { background: var(--accent); color: var(--accent-text); }
.tile.active::before {
content: ''; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 4px; background: var(--text);
}
.tile.dragged { opacity: 0.4; }
.icon { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
/* Connection state: grey dot connecting, green connected, green with ring where voice runs */
.tile.live::after, .tile.connecting::after {
content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px;
background: var(--speaking); border: 2px solid var(--bg-0);
}
.tile.connecting::after { background: var(--text-faint); }
.tile.voice::after { box-shadow: 0 0 0 2px var(--speaking); }
.unread {
position: absolute; right: -6px; top: -6px; min-width: 18px; padding: 0 4px; line-height: 16px;
font-size: 10px; background: var(--danger); color: #fff; border: 2px solid var(--bg-0);
}
.drop-line { width: 44px; height: 3px; flex: none; background: var(--accent); margin: -5px 0; }
.add { color: var(--speaking); background: var(--bg-2); }
.add:hover { background: var(--speaking); color: var(--bg-0); }
.ghost { background: transparent; color: var(--text-dim); }
.ghost:hover { background: var(--bg-3); color: var(--text); }
.spacer { flex: 1; }
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import Modal from './Modal.svelte';
import VoiceSettings from './VoiceSettings.svelte';
import IdentityList from './IdentityList.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
// Changes apply right away
function saveFiles() {
s.uploadHost = s.uploadHost.trim().replace(/\/+$/, '');
s.uploadKey = s.uploadKey.trim();
s.embedHosts = embedHosts.split(/[\s,]+/).map(h => h.trim()).filter(Boolean);
store.saveSettings();
}
async function test() {
saveFiles();
testResult = 'Checking...';
try {
const info = await fetchUploadInfo(s.uploadHost, s.uploadKey);
testResult = `Key works. Allowed: ${info.allowed_extensions.join(', ')} (max ${Math.round(info.max_bytes / 1048576)} MB)`;
} catch (e) {
testResult = (e as Error).message;
}
}
type Tab = 'voice' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
</script>
<Modal title="Settings" onclose={() => { saveFiles(); onclose(); }} width={640}>
<div class="tabs" role="tablist">
{#each tabs as t (t.id)}
<button role="tab" aria-selected={tab === t.id} class:active={tab === t.id} onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'voice'}
<VoiceSettings />
{: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>
<input id="s-host" bind:value={s.uploadHost} onchange={saveFiles} placeholder="https://f0ck.dev" />
<label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row">
<div>
<label for="s-exp">Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}>
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button>
</div>
{#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label>
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
<label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</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>
<IdentityList />
{/if}
{#snippet footer()}
<button class="btn primary" onclick={() => { saveFiles(); onclose(); }}>Done</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; white-space: nowrap; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
h3 { margin: 22px 0 0; font-size: 14px; }
.help { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }
.row { display: flex; align-items: flex-end; gap: 8px; }
.row > div { flex: 1; }
.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; }
textarea { resize: vertical; }
</style>
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import ChannelNode from './ChannelNode.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
const roots = $derived(session.childrenOf(null));
const self = $derived(session.self);
const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized);
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
function showServerCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`,
chain: cert.chain,
status: trusted
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
const dms = $derived(
Object.keys(session.logs).filter(k => k.startsWith('dm:')).map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
);
</script>
<aside class="sidebar">
<header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
<span class="status">
{#if session.status === 'connected'}
<span class="dot"></span>Connected{#if session.rtt} , {session.rtt} ms{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
<div class="scroll">
{#if session.status === 'connected'}
<ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
</ul>
{#if dms.length}
<h3>Direct messages</h3>
<ul class="tree">
{#each dms as dm (dm.key)}
<li>
<button class="dm" class:viewing={session.view === dm.key}
onclick={() => { session.selectView(dm.key); onnavigate(); }}>
<Avatar name={dm.user?.name ?? '?'} size={22} />
<span class="label" class:offline={!dm.user}>{dm.user?.name ?? 'Offline user'}</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.error}
<p class="error">{session.error}</p>
{:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
{/if}
</div>
{#if self && sessions.voiceSession && sessions.voiceSession !== sessions.active}
<div class="elsewhere">
<span>Voice is on <strong>{sessions.voiceSession.server?.label}</strong>, deafened here</span>
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div>
{/if}
{#if self}
<footer class="me">
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} />
<span class="who">
<strong>{self.name}</strong>
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span>
</button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
aria-label={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</footer>
{/if}
</aside>
<style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
header.trusted .dot { background: #fff; }
.title { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; padding-left: 14px; text-align: left; }
.title:disabled { opacity: 1; }
.title:hover:not(:disabled) { background: rgb(255 255 255 / 0.05); }
.title strong { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { font-size: 12px; color: var(--text-dim); display: flex; align-items: center; gap: 6px; }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.scroll { flex: 1; overflow-y: auto; padding: 8px 6px; }
ul { list-style: none; margin: 0; padding: 0; }
h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-faint); margin: 18px 8px 6px; }
.dm { display: flex; align-items: center; gap: 8px; width: 100%; padding: 4px 8px; color: var(--text-dim); text-align: left; }
.dm:hover { background: var(--bg-hover); color: var(--text); }
.dm.viewing { background: var(--bg-active); color: var(--text); }
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.offline { color: var(--text-faint); font-style: italic; }
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
.hint, .error { padding: 8px; color: var(--text-dim); font-size: 14px; }
.error { color: var(--danger); }
.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; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
.profile { flex: 1; 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); }
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { native } from '../lib/native.ts';
import { formatBytes } from '../lib/html.ts';
import type { CertDetails } from '../core/transport.ts';
let { target, onclose }: { target: number; onclose: () => void } = $props();
// The dialog is remounted per user ({#key} in App), so the session is fixed for its lifetime
// svelte-ignore state_referenced_locally
const who = target;
// The server fills in only what we may see: details for ourselves and admins,
// packet statistics for people in our channel, times and pings for everyone.
let stats = $state<any>(null);
let certs = $state<CertDetails[]>([]);
let updated = $state(0);
const client = session.client;
const user = $derived(session.user(who));
const off = client?.on('userStats', msg => {
if (msg.session !== who) return;
// Refreshes ask for statistics only; keep the details from the first answer
stats = stats && !msg.version ? { ...stats, ...msg, version: stats.version, certificates: stats.certificates, address: stats.address } : msg;
updated = Date.now();
if (msg.certificates?.length && !certs.length && native) {
native.describeCerts(msg.certificates).then(c => (certs = c));
}
});
client?.requestUserStats(who);
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
onDestroy(() => { off?.(); clearInterval(timer); });
function duration(secs?: number): string {
if (secs == null) return 'unknown';
const d = Math.floor(secs / 86400), h = Math.floor(secs / 3600) % 24, m = Math.floor(secs / 60) % 60, s = secs % 60;
return [d && `${d}d`, (d || h) && `${h}h`, (d || h || m) && `${m}m`, `${s}s`].filter(Boolean).join(' ');
}
function address(bytes?: Uint8Array): string {
if (!bytes?.length) return '';
const b = [...bytes];
// IPv4-mapped IPv6 (::ffff:a.b.c.d)
if (b.length === 16 && b.slice(0, 10).every(x => x === 0) && b[10] === 255 && b[11] === 255) return b.slice(12).join('.');
const groups = [];
for (let i = 0; i < b.length; i += 2) groups.push(((b[i] << 8) | b[i + 1]).toString(16));
return groups.join(':').replace(/(^|:)0(:0)+(:|$)/, '::');
}
const version = $derived.by(() => {
const v = stats?.version;
if (!v) return '';
const n = v.version_v2 ? Number(v.version_v2) : 0;
const num = n ? `${Math.floor(n / 2 ** 48)}.${Math.floor(n / 2 ** 32) % 65536}.${Math.floor(n / 2 ** 16) % 65536}`
: v.version_v1 ? `${v.version_v1 >> 16}.${(v.version_v1 >> 8) & 255}.${v.version_v1 & 255}` : '';
return [v.release, num && !String(v.release ?? '').includes(num) ? `(protocol ${num})` : ''].filter(Boolean).join(' ');
});
const ping = (avg?: number, v?: number) => avg ? `${avg.toFixed(1)} ms${v ? ` (variance ${Math.sqrt(v).toFixed(1)})` : ''}` : 'n/a';
const loss = (s: any) => {
const total = (s?.good ?? 0) + (s?.late ?? 0) + (s?.lost ?? 0);
return total ? `${(((s?.lost ?? 0) / total) * 100).toFixed(1)}%` : '-';
};
</script>
<Modal title="Information: {user ? store.displayName(user) : `#${target}`}" {onclose} width={560}>
{#if !stats}
<p class="dim">Asking the server...</p>
{:else}
<h3>Connection</h3>
<dl>
<dt>Online for</dt><dd>{duration(stats.onlinesecs)}</dd>
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs)}</dd>{/if}
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} 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}
</dl>
{#if stats.version}
<h3>Client</h3>
<dl>
<dt>Version</dt><dd>{version || 'unknown'}</dd>
{#if stats.version.os}<dt>System</dt><dd>{stats.version.os} {stats.version.os_version ?? ''}</dd>{/if}
<dt>Opus</dt><dd>{stats.opus ? 'supported' : 'no (legacy codecs only)'}</dd>
</dl>
{/if}
{#if stats.from_client || stats.from_server}
<h3>Voice packets</h3>
<table>
<thead><tr><th></th><th>Good</th><th>Late</th><th>Lost</th><th>Resync</th><th>Loss</th></tr></thead>
<tbody>
<tr><th>From client</th><td>{stats.from_client?.good ?? 0}</td><td>{stats.from_client?.late ?? 0}</td><td>{stats.from_client?.lost ?? 0}</td><td>{stats.from_client?.resync ?? 0}</td><td>{loss(stats.from_client)}</td></tr>
<tr><th>To client</th><td>{stats.from_server?.good ?? 0}</td><td>{stats.from_server?.late ?? 0}</td><td>{stats.from_server?.lost ?? 0}</td><td>{stats.from_server?.resync ?? 0}</td><td>{loss(stats.from_server)}</td></tr>
</tbody>
</table>
<p class="dim small">Counted on the encrypted UDP channel; clients sending voice over TCP show zeros.</p>
{/if}
{#if stats.certificates?.length}
<h3>Certificate</h3>
<dl>
<dt>Subject</dt><dd>{certs[0]?.subject.replace(/\n/g, ', ') ?? '...'}</dd>
<dt>Verified</dt><dd>{stats.strong_certificate ? 'yes, signed by a trusted authority' : 'no, self-signed'}</dd>
</dl>
{#if certs.length}
<button class="btn cert" onclick={() => (ui.cert = { title: `Certificate of ${user?.name ?? target}`, chain: certs })}>View certificate</button>
{/if}
{:else if user?.hash}
<h3>Certificate</h3>
<dl><dt>SHA-1</dt><dd class="mono">{user.hash}</dd></dl>
<p class="dim small">Only admins and the user themselves can see the full certificate.</p>
{/if}
{#if !stats.version}
<p class="dim small note">Client details, address and certificate are only shown to admins and to the user themselves.</p>
{/if}
{/if}
{#snippet footer()}
{#if updated}<span class="dim small upd">Updates every 5 seconds</span>{/if}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 4px 6px; text-align: right; border-bottom: 1px solid var(--line); }
th:first-child { text-align: left; color: var(--text-faint); font-weight: 400; }
thead th { color: var(--text-faint); font-weight: 600; }
.dim { color: var(--text-dim); margin: 0; }
.small { font-size: 12px; margin-top: 6px; }
.note { margin-top: 16px; }
.cert { margin-top: 10px; }
.upd { margin-right: auto; align-self: center; }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { voice, type TransmitMode } from '../lib/audio/voice.svelte.ts';
import { session } from '../lib/session.svelte.ts';
const s = voice.settings;
let capturing = $state(false);
// Opening the settings starts audio so the meter and device names work before connecting
voice.start();
onDestroy(() => { if (voice.testing) voice.setTesting(false); if (session.status !== 'connected') voice.stop(); });
const METER_MIN = -80;
const pct = (db: number) => Math.max(0, Math.min(100, ((db - METER_MIN) / -METER_MIN) * 100));
const open = $derived(voice.level > s.vadThreshold);
const keyName = (code: string) => code
.replace(/^Key/, '').replace(/^Digit/, '').replace(/^Numpad/, 'Num ')
.replace('Backquote', 'Backtick (`)').replace(/([a-z])([A-Z])/g, '$1 $2');
function captureKey(e: KeyboardEvent) {
if (!capturing) return;
e.preventDefault();
e.stopPropagation();
if (e.code !== 'Escape') voice.update({ pttKey: e.code });
capturing = false;
}
const modes: { id: TransmitMode; label: string; help: string }[] = [
{ id: 'vad', label: 'Voice activity', help: 'Sends when you speak louder than the threshold' },
{ id: 'ptt', label: 'Push to talk', help: 'Sends while you hold the key' },
{ id: 'continuous', label: 'Always on', help: 'Sends all the time' }
];
</script>
<svelte:window onkeydowncapture={captureKey} />
<div class="voice">
{#if voice.error}<p class="error">{voice.error}</p>{/if}
<h3>Devices</h3>
<div class="grid">
<div>
<label for="v-in">Microphone</label>
<select id="v-in" value={s.inputDevice} onchange={e => voice.update({ inputDevice: e.currentTarget.value })}>
{#each voice.inputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
<div>
<label for="v-out">Speakers / headphones</label>
<select id="v-out" value={s.outputDevice} onchange={e => voice.update({ outputDevice: e.currentTarget.value })}>
{#each voice.outputDevices as d (d.id)}<option value={d.id}>{d.label}</option>{:else}<option value="default">System default</option>{/each}
</select>
</div>
</div>
<h3>Transmit</h3>
<div class="modes" role="radiogroup" aria-label="Transmit mode">
{#each modes as m (m.id)}
<button role="radio" aria-checked={s.mode === m.id} class:active={s.mode === m.id} onclick={() => voice.update({ mode: m.id })}>
<strong>{m.label}</strong><small>{m.help}</small>
</button>
{/each}
</div>
{#if s.mode === 'ptt'}
<label for="v-ptt">Push-to-talk key</label>
<div class="row">
<button id="v-ptt" class="btn key" class:capturing onclick={() => (capturing = true)}>
{capturing ? 'Press a key... (Esc cancels)' : keyName(s.pttKey)}
</button>
<span class="hint" class:on={voice.pttDown}>{voice.pttDown ? 'Talking' : 'Hold the key to talk'}</span>
</div>
<p class="help">Works while mumh5 is focused. System-wide push to talk is planned.</p>
{/if}
<label for="v-level">Input level{s.mode === 'vad' ? ' and threshold' : ''}</label>
<div class="meter" class:open={s.mode !== 'vad' || open} aria-hidden="true">
<div class="fill" style:width="{pct(voice.level)}%"></div>
{#if s.mode === 'vad'}<div class="mark" style:left="{pct(s.vadThreshold)}%"></div>{/if}
</div>
{#if s.mode === 'vad'}
<input id="v-level" type="range" min={METER_MIN} max="0" step="1" value={s.vadThreshold}
oninput={e => voice.update({ vadThreshold: Number(e.currentTarget.value) })} aria-label="Voice activity threshold" />
<p class="help">Speak normally and put the marker just below your voice; the bar turns green when you would be sending. Threshold {s.vadThreshold} dB.</p>
<label for="v-hold">Keep sending after speech: {s.vadHoldMs} ms</label>
<input id="v-hold" type="range" min="100" max="1500" step="50" value={s.vadHoldMs}
oninput={e => voice.update({ vadHoldMs: Number(e.currentTarget.value) })} />
{/if}
<div class="grid">
<div>
<label for="v-gain">Microphone volume: {Math.round(s.inputGain * 100)}%</label>
<input id="v-gain" type="range" min="0" max="3" step="0.05" value={s.inputGain}
oninput={e => voice.update({ inputGain: Number(e.currentTarget.value) })} />
</div>
<div>
<label for="v-vol">Output volume: {Math.round(s.outputVolume * 100)}%</label>
<input id="v-vol" type="range" min="0" max="2" step="0.05" value={s.outputVolume}
oninput={e => voice.update({ outputVolume: Number(e.currentTarget.value) })} />
</div>
</div>
<div class="row test">
<button class="btn" class:primary={voice.testing} onclick={() => voice.setTesting(!voice.testing)}>
{voice.testing ? 'Stop test' : 'Test microphone'}
</button>
<span class="help">{voice.testing ? 'You should hear yourself. Use headphones to avoid feedback.' : 'Plays your microphone back to you'}</span>
</div>
<h3>Processing</h3>
<label class="check"><input type="checkbox" checked={s.noiseSuppression} onchange={e => voice.update({ noiseSuppression: e.currentTarget.checked })} /> Noise suppression</label>
<label class="check"><input type="checkbox" checked={s.echoCancellation} onchange={e => voice.update({ echoCancellation: e.currentTarget.checked })} /> Echo cancellation (for speakers instead of headphones)</label>
<label class="check"><input type="checkbox" checked={s.autoGainControl} onchange={e => voice.update({ autoGainControl: e.currentTarget.checked })} /> Automatic gain control</label>
<h3>Quality</h3>
<div class="grid">
<div>
<label for="v-br">Bitrate: {Math.round(s.bitrate / 1000)} kbit/s</label>
<input id="v-br" type="range" min="8000" max="128000" step="4000" value={s.bitrate}
oninput={e => voice.update({ bitrate: Number(e.currentTarget.value) })} />
{#if voice.effectiveBitrate && voice.effectiveBitrate < s.bitrate && session.status === 'connected'}
<p class="help warn">Limited to {Math.round(voice.effectiveBitrate / 1000)} kbit/s by this server</p>
{/if}
</div>
<div>
<label for="v-frame">Audio per packet</label>
<select id="v-frame" value={String(s.frameMs)} onchange={e => voice.update({ frameMs: Number(e.currentTarget.value) as 10 | 20 | 40 | 60 })}>
<option value="10">10 ms (lowest latency)</option>
<option value="20">20 ms (recommended)</option>
<option value="40">40 ms</option>
<option value="60">60 ms (least bandwidth)</option>
</select>
</div>
</div>
<label for="v-jit">Jitter buffer: {s.jitterMs} ms</label>
<input id="v-jit" type="range" min="20" max="300" step="10" value={s.jitterMs}
oninput={e => voice.update({ jitterMs: Number(e.currentTarget.value) })} />
<p class="help">Higher values smooth out bad connections at the cost of delay.</p>
{#if session.status === 'connected'}
<h3>Statistics</h3>
<p class="help stats">Sent {voice.stats.sent} packets, received {voice.stats.received}, decoded {voice.stats.decoded}, gaps {voice.stats.lost}. Transport: TCP tunnel.</p>
{/if}
</div>
<style>
h3 { margin: 22px 0 0; font-size: 14px; }
h3:first-of-type { margin-top: 8px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
@media (max-width: 560px) { .grid { grid-template-columns: 1fr; } }
.modes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
@media (max-width: 560px) { .modes { grid-template-columns: 1fr; } }
.modes button { display: flex; flex-direction: column; gap: 2px; padding: 10px; text-align: left; background: var(--bg-0); border: 1px solid var(--line); }
.modes button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.modes small { color: var(--text-dim); font-size: 12px; }
.row { display: flex; align-items: center; gap: 12px; }
.key { min-width: 160px; }
.key.capturing { border-color: var(--accent); color: var(--accent-hover); }
.hint { font-size: 13px; color: var(--text-dim); }
.hint.on { color: var(--speaking); font-weight: 600; }
.meter { position: relative; height: 12px; background: var(--bg-0); border: 1px solid var(--line); overflow: hidden; }
.fill { height: 100%; background: var(--text-faint); transition: width 50ms linear; }
.meter.open .fill { background: var(--speaking); }
.mark { position: absolute; top: -1px; bottom: -1px; width: 2px; background: var(--warn); }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.test { margin-top: 14px; }
.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: 10px; }
.check input { width: auto; }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.warn { color: var(--warn); }
.error { color: var(--danger); margin: 8px 0 0; }
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { store } from '../lib/settings.svelte.ts';
let { videoId, start = 0, url }: { videoId: string; start?: number; url: string } = $props();
// Nothing is loaded from YouTube until the user clicks (unless thumbnails are enabled)
let playing = $state(false);
const src = $derived(`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0${start ? `&start=${start}` : ''}`);
const thumb = $derived(store.settings.youtube === 'thumbnail' ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : '');
</script>
<div class="yt">
{#if playing}
<iframe {src} title="YouTube video" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen
referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"></iframe>
{:else}
<button class="facade" style:background-image={thumb ? `url(${thumb})` : undefined} onclick={() => (playing = true)}
title="Play here (loads from YouTube)">
<span class="play"><svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4l14 8-14 8z" fill="currentColor" /></svg></span>
<span class="label">
<strong>YouTube</strong>
<span>{start ? `from ${Math.floor(start / 60)}:${String(start % 60).padStart(2, '0')}, ` : ''}click to play</span>
</span>
</button>
{/if}
<a class="open" href={url} target="_blank" rel="noopener noreferrer" title="Open on YouTube"><Icon name="link" size={14} /></a>
</div>
<style>
.yt { position: relative; width: min(480px, 100%); aspect-ratio: 16 / 9; background: #000; border: 1px solid var(--line); }
iframe { display: block; width: 100%; height: 100%; border: 0; }
.facade { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
background: #111 center / cover no-repeat; color: #fff; }
.facade::before { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgb(0 0 0 / 0.7)); }
.play { position: relative; width: 64px; height: 44px; display: flex; align-items: center; justify-content: center; background: #e0443e; transition: transform 100ms; }
.facade:hover .play { transform: scale(1.08); }
.label { position: relative; display: flex; flex-direction: column; align-items: center; font-size: 13px; text-shadow: 0 1px 2px #000; }
.label span { color: #ddd; }
.open { position: absolute; top: 6px; right: 6px; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 0.6); color: #fff; }
.open:hover { background: rgb(0 0 0 / 0.85); text-decoration: none; }
</style>