Add server information, an always-visible mute bar and public list actions
- Public servers: connect arrow and add button per row, sortable column headers - Mute and deafen stay available while disconnected; the choice carries to the next connect - Server information dialog, opened from a button next to mute - Stacked layout: mute bar on top, without the profile - Settings that need the upload host are disabled until it is set; link previews show the mode in effect - YouTube links are plain links by default - Composer has a border and matches the sidebar bar height - Regenerate README screenshots Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -132,11 +132,11 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */
|
||||
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); }
|
||||
.frame { background: var(--bg-3); }
|
||||
/* Same height as the bar at the bottom of the sidebar, buttons centered like there; grows for longer messages */
|
||||
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); border: 1px solid var(--line); }
|
||||
.frame { flex: 1; display: flex; flex-direction: column; background: var(--bg-3); }
|
||||
.frame :global(.toolbar) { background: var(--bg-2); }
|
||||
.box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; }
|
||||
.box { flex: 1; display: flex; align-items: center; gap: 4px; padding: 0 8px; }
|
||||
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
|
||||
.input :global(img) { max-height: 120px; }
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { native, type PublicServer, type PingResult } from '../lib/native.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
import { sessions } from '../lib/session.svelte.ts';
|
||||
|
||||
let { onclose }: { onclose: () => void } = $props();
|
||||
|
||||
@@ -12,7 +14,10 @@
|
||||
let error = $state('');
|
||||
let search = $state('');
|
||||
let continent = $state('');
|
||||
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users');
|
||||
type Sort = 'users' | 'ping' | 'name' | 'country';
|
||||
let sort = $state<Sort>('users');
|
||||
// Each column has a natural order (most people, lowest ping, A to Z); a second click flips it
|
||||
let reversed = $state(false);
|
||||
let pings = $state<Record<string, PingResult | null>>({});
|
||||
let alive = true;
|
||||
// Ping queue; the generation drops answers from a previous list
|
||||
@@ -53,14 +58,24 @@
|
||||
const list = servers.filter(s => (!continent || s.continent === continent) &&
|
||||
(!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q)));
|
||||
const p = (s: PublicServer) => pings[key(s)];
|
||||
const dir = reversed ? -1 : 1;
|
||||
return list.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
|
||||
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9);
|
||||
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1);
|
||||
if (sort === 'name') return dir * a.name.localeCompare(b.name);
|
||||
if (sort === 'country') return dir * a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
|
||||
// Servers that have not answered stay at the bottom in both directions
|
||||
const pa = p(a), pb = p(b);
|
||||
if (!pa || !pb) return pa ? -1 : pb ? 1 : 0;
|
||||
return dir * (sort === 'ping' ? pa.ping - pb.ping : pb.users - pa.users);
|
||||
}).slice(0, 300);
|
||||
});
|
||||
|
||||
function sortBy(col: Sort) {
|
||||
reversed = sort === col && !reversed;
|
||||
sort = col;
|
||||
}
|
||||
// Most people first counts as descending, the other columns start ascending
|
||||
const ariaSort = (col: Sort) => sort !== col ? 'none' : reversed === (col === 'users') ? 'ascending' : 'descending';
|
||||
|
||||
// Ping what the filter shows, a few at a time; each answer fills in users and latency
|
||||
function pingVisible() {
|
||||
queue = servers.filter(s => !(key(s) in pings));
|
||||
@@ -80,6 +95,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Connects right away with the name used last; the server is saved so it shows in the rail.
|
||||
// Without a name to reuse, the add dialog asks for one.
|
||||
function connect(s: PublicServer) {
|
||||
const saved = store.servers.find(x => x.host === s.host && x.port === s.port);
|
||||
const username = store.servers.at(-1)?.username;
|
||||
if (!saved && !username) return add(s);
|
||||
const server = saved ?? { id: crypto.randomUUID(), label: s.name || s.host, host: s.host, port: s.port, username: username!, password: '' };
|
||||
if (!saved) store.upsertServer(server);
|
||||
sessions.open(server);
|
||||
onclose();
|
||||
}
|
||||
|
||||
function add(s: PublicServer) {
|
||||
ui.addServer = { host: s.host, port: s.port, label: s.name };
|
||||
onclose();
|
||||
@@ -106,7 +133,7 @@
|
||||
<option value="">All continents</option>
|
||||
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={sort} aria-label="Sort by">
|
||||
<select bind:value={sort} onchange={() => (reversed = false)} aria-label="Sort by">
|
||||
<option value="users">Most people</option>
|
||||
<option value="ping">Lowest ping</option>
|
||||
<option value="name">Name</option>
|
||||
@@ -121,7 +148,14 @@
|
||||
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead>
|
||||
<thead><tr>
|
||||
{#each [['name', 'Server'], ['country', 'Country'], ['users', 'People'], ['ping', 'Ping']] as const as [col, text] (col)}
|
||||
<th class:num={col === 'users' || col === 'ping'} aria-sort={ariaSort(col)}>
|
||||
<button class="sort" class:on={sort === col} title="Sort by {text.toLowerCase()}" onclick={() => sortBy(col)}>{text}<span class="caret" class:up={ariaSort(col) === 'ascending'}></span></button>
|
||||
</th>
|
||||
{/each}
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{#each shown as s (key(s))}
|
||||
{@const p = pings[key(s)]}
|
||||
@@ -134,7 +168,12 @@
|
||||
<td>{s.country}</td>
|
||||
<td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td>
|
||||
<td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td>
|
||||
<td class="act"><button class="btn" onclick={() => add(s)}>Add</button></td>
|
||||
<td class="act">
|
||||
<span class="acts">
|
||||
<button class="icon-btn" title="Add to your servers" aria-label="Add" onclick={() => add(s)}><Icon name="plus" size={18} /></button>
|
||||
<button class="icon-btn go" title="Connect" aria-label="Connect" onclick={() => connect(s)}><Icon name="arrow-right" size={18} /></button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="5" class="dim">No servers match.</td></tr>
|
||||
@@ -157,6 +196,12 @@
|
||||
.bar select { width: auto; flex: 0 0 auto; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; }
|
||||
th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); }
|
||||
.sort { font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.sort:hover, .sort.on { color: var(--text); }
|
||||
/* Triangle from borders; only the active column shows it */
|
||||
.caret { border: 4px solid transparent; border-top-color: currentColor; margin-top: 4px; visibility: hidden; }
|
||||
.caret.up { border-top-color: transparent; border-bottom-color: currentColor; margin-top: -4px; }
|
||||
.sort.on .caret { visibility: visible; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
tr:hover td { background: var(--bg-hover); }
|
||||
.name { max-width: 360px; }
|
||||
@@ -167,7 +212,8 @@
|
||||
.good { color: var(--speaking); }
|
||||
.bad { color: var(--warn); }
|
||||
.act { width: 1%; }
|
||||
.act .btn { padding: 4px 12px; font-size: 13px; }
|
||||
.acts { display: flex; gap: 4px; }
|
||||
.go, .go:hover { color: var(--speaking); }
|
||||
.dim { color: var(--text-dim); }
|
||||
.small { font-size: 12px; margin-top: 10px; }
|
||||
.error { color: var(--danger); }
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
|
||||
let { onclose }: { onclose: () => void } = $props();
|
||||
|
||||
const info = $derived(session.serverInfo);
|
||||
const server = $derived(session.server);
|
||||
const cert = $derived(session.serverCert);
|
||||
const voice = $derived(session.voiceTransport);
|
||||
</script>
|
||||
|
||||
<Modal title="Server information" {onclose} width={520}>
|
||||
{#if info && server}
|
||||
<h3>Server</h3>
|
||||
<dl>
|
||||
<dt>Address</dt><dd class="mono">{server.host}:{server.port}</dd>
|
||||
<dt>Version</dt><dd>{info.version || 'unknown'}</dd>
|
||||
<dt>People</dt><dd>{info.users}{info.config.maxUsers ? ` of ${info.config.maxUsers}` : ''}</dd>
|
||||
<dt>Channels</dt><dd>{info.channels}</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Connection</h3>
|
||||
<dl>
|
||||
<dt>Ping (TCP)</dt><dd>{session.rtt ? `${session.rtt} ms` : 'n/a'}</dd>
|
||||
<dt>Voice</dt><dd>{voice.udp ? `Encrypted UDP${voice.rtt ? `, ${Math.round(voice.rtt)} ms` : ''}` : 'Through the TCP connection'}</dd>
|
||||
<dt>Certificate</dt><dd>{cert ? (cert.authorized ? 'Signed by a trusted authority' : 'Self-signed or untrusted, pinned on first connect') : 'unknown'}</dd>
|
||||
{#if cert}<dt>SHA-256</dt><dd class="mono">{cert.fingerprint}</dd>{/if}
|
||||
</dl>
|
||||
|
||||
<h3>Limits</h3>
|
||||
<dl>
|
||||
<dt>Voice bitrate</dt><dd>{info.maxBandwidth ? `up to ${Math.round(info.maxBandwidth / 1000)} kbit/s` : 'unknown'}</dd>
|
||||
<dt>Message length</dt><dd>{info.config.messageLength ? `${info.config.messageLength} characters` : 'unlimited'}</dd>
|
||||
<dt>Image messages</dt><dd>{info.config.imageMessageLength ? `${info.config.imageMessageLength} characters` : 'unlimited'}</dd>
|
||||
<dt>HTML in messages</dt><dd>{info.config.allowHtml ? 'allowed' : 'not allowed'}</dd>
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<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); }
|
||||
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
import { native } from '../lib/native.ts';
|
||||
import { fetchUploadInfo } from '../lib/upload.ts';
|
||||
import { previewMode } from '../lib/preview.ts';
|
||||
|
||||
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
|
||||
|
||||
@@ -15,6 +16,9 @@
|
||||
const s = store.settings;
|
||||
let embedHosts = $state(s.embedHosts.join('\n'));
|
||||
let testResult = $state('');
|
||||
// Settings that only work through the upload host are disabled until it is set up
|
||||
const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim()));
|
||||
const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key');
|
||||
|
||||
// Changes apply right away
|
||||
function saveFiles() {
|
||||
@@ -65,36 +69,36 @@
|
||||
<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}>
|
||||
<div title={needsUpload}>
|
||||
<label for="s-exp" class:off={!hasUpload}>Files expire after</label>
|
||||
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles} disabled={!hasUpload}>
|
||||
<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>
|
||||
<button class="btn" disabled={!hasUpload} title={needsUpload} 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>
|
||||
<label class="check" class:off={!hasUpload} title={needsUpload}><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} disabled={!hasUpload} /> 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-lp">Link previews</label>
|
||||
<select id="s-lp" bind:value={s.linkPreviews} onchange={saveFiles}>
|
||||
<option value="host">Fetched by the upload host (keeps your IP private)</option>
|
||||
<!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
|
||||
<select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
|
||||
<option value="host" disabled={!hasUpload}>Fetched by the upload host (keeps your IP private){hasUpload ? '' : ' - needs an upload host and key'}</option>
|
||||
<option value="direct">Fetched by this computer (linked sites see your IP)</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
{#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}<p class="help">Needs an upload host and key above; until then no previews are shown.</p>{/if}
|
||||
<label for="s-yt">YouTube links</label>
|
||||
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
|
||||
<option value="off">Plain links</option>
|
||||
<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>
|
||||
@@ -116,5 +120,7 @@
|
||||
.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; }
|
||||
.off { color: var(--text-faint); }
|
||||
select:disabled, input:disabled { opacity: 0.5; }
|
||||
textarea { resize: vertical; }
|
||||
</style>
|
||||
|
||||
+31
-14
@@ -22,6 +22,9 @@
|
||||
|
||||
const roots = $derived(session.childrenOf(null));
|
||||
const self = $derived(session.self);
|
||||
// Without a connection the buttons show the remembered choice, applied at the next connect
|
||||
const muted = $derived(self ? self.selfMute : sessions.mute);
|
||||
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
|
||||
const cert = $derived(session.serverCert);
|
||||
const trusted = $derived(!!cert?.authorized);
|
||||
|
||||
@@ -111,8 +114,8 @@
|
||||
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if self}
|
||||
<footer class="me">
|
||||
<footer class="me">
|
||||
{#if self}
|
||||
<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} />
|
||||
@@ -121,18 +124,27 @@
|
||||
<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}
|
||||
{:else}
|
||||
<span class="who offline-who" title="Mute and deafen apply when you connect">
|
||||
<strong>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</strong>
|
||||
<span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<button class="icon-btn info" title="Server information" aria-label="Server information"
|
||||
disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
|
||||
<Icon name="info" size={18} />
|
||||
</button>
|
||||
<button class="icon-btn" class:on={muted} title={muted ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
|
||||
aria-label={muted ? 'Unmute' : 'Mute'}
|
||||
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => openQuick(e, 'input')}>
|
||||
<Icon name={muted ? 'mic-off' : 'mic'} size={18} />
|
||||
</button>
|
||||
<button class="icon-btn" class:on={deafened} title={deafened ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
|
||||
aria-label={deafened ? 'Undeafen' : 'Deafen'}
|
||||
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
|
||||
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
@@ -163,9 +175,14 @@
|
||||
.elsewhere span { flex: 1; min-width: 0; }
|
||||
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
|
||||
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); }
|
||||
/* Stacked layout: the bar leads the stack, like the desktop client's toolbar */
|
||||
.sidebar.fill .me { order: -1; border-bottom: 1px solid var(--line); }
|
||||
.sidebar.fill .me .profile, .sidebar.fill .me .offline-who { display: none; }
|
||||
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
|
||||
.profile:hover { background: var(--bg-hover); }
|
||||
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.who span { font-size: 12px; color: var(--text-dim); }
|
||||
.info, .info:hover { color: var(--warn); }
|
||||
.offline-who { padding: 2px 2px 2px 6px; color: var(--text-dim); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user