Add link previews, voice tiles and a menu volume slider; tidy the profile

- Link previews for web links in chat, fetched by the f0ckm upload host by
  default (linked sites never see the user's IP), or directly, or off
- Voice channel tiles above the chat on windows too narrow for the member list
- Per-person volume slider in the user context menu
- Profile panel: channel on its own line under the name, no Guest badge,
  voice actions moved to the context menu
- User information dialog refreshes every second
- Rate limiting mirrors Murmur's whole-second bucket leak, so irregular
  bursts are queued instead of dropped
- CHANGELOG.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:01:30 +02:00
co-authored by Claude Opus 5.5
parent 6d29ee1485
commit aa679dddcc
29 changed files with 541 additions and 60 deletions
+1 -1
View File
@@ -60,7 +60,7 @@
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
{/if}
<Chat showMenu={layout === 'narrow'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
<Chat showMenu={layout === 'narrow'} showStage={layout !== 'wide'} onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{#if showPanel}
{#if layout === 'wide'}
+20 -17
View File
@@ -7,10 +7,12 @@ const VERSION = { major: 1, minor: 5, patch: 0 };
const PING_INTERVAL_MS = 15000;
// Murmur drops these messages without telling the client when they come too fast
// (leaky bucket, defaults messageburst=5 and messagelimit=1/s). Mirroring the bucket and
// queueing keeps quick actions from getting lost. Slightly slower refill for safety.
// (leaky bucket, defaults messageburst=5 and messagelimit=1/s). Its bucket restarts its timer
// on every limited message and only leaks whole seconds, discarding the rest; the client
// mirrors exactly that and queues instead of losing messages. The margin absorbs network jitter.
const RATE_BURST = 5;
const RATE_REFILL_MS = 1100;
const RATE_LEAK_MS = 1000;
const RATE_MARGIN_MS = 250;
const RATE_LIMITED = new Set<MessageName>(['TextMessage', 'ChannelState', 'ACL', 'Version']);
export interface Channel {
@@ -142,8 +144,8 @@ export class MumbleClient extends Emitter<ClientEvents> {
private reader = new FrameReader();
private pingTimer: ReturnType<typeof setInterval> | null = null;
private closed = false;
private tokens = RATE_BURST;
private lastRefill = Date.now();
private bucket = 0; // our estimate of the server's bucket level
private lastLimited = 0; // when the last rate-limited message was sent
private queue: Uint8Array[] = [];
private queueTimer: ReturnType<typeof setTimeout> | null = null;
@@ -206,14 +208,10 @@ export class MumbleClient extends Emitter<ClientEvents> {
return this.queue.length;
}
private refill(): void {
const now = Date.now();
const gained = Math.floor((now - this.lastRefill) / RATE_REFILL_MS);
if (gained > 0) {
this.tokens = Math.min(RATE_BURST, this.tokens + gained);
this.lastRefill += gained * RATE_REFILL_MS;
}
if (this.tokens === RATE_BURST) this.lastRefill = now;
// Bucket level the server will compute when the next message arrives now
private levelAt(now: number): number {
const leaked = Math.floor(Math.max(0, now - this.lastLimited - RATE_MARGIN_MS) / RATE_LEAK_MS);
return Math.max(0, this.bucket - leaked);
}
private sendPaced(bytes: Uint8Array): void {
@@ -223,13 +221,18 @@ export class MumbleClient extends Emitter<ClientEvents> {
private drain(): void {
if (this.queueTimer || this.closed) return;
this.refill();
while (this.queue.length && this.tokens > 0) {
this.tokens--;
while (this.queue.length) {
const now = Date.now();
const level = this.levelAt(now);
if (level + 1 > RATE_BURST) break;
this.bucket = level + 1;
this.lastLimited = now;
this.transport?.send(this.queue.shift()!);
}
if (this.queue.length) {
this.queueTimer = setTimeout(() => { this.queueTimer = null; this.drain(); }, RATE_REFILL_MS - (Date.now() - this.lastRefill) + 5);
// A full bucket leaks one message once a whole second (plus margin) has passed
const wait = this.lastLimited + RATE_LEAK_MS + RATE_MARGIN_MS - Date.now() + 5;
this.queueTimer = setTimeout(() => { this.queueTimer = null; this.drain(); }, Math.max(5, wait));
}
}
+10 -2
View File
@@ -27,8 +27,7 @@ export function userMenu(u: User): MenuItem[] {
const self = session.self;
const isSelf = u.session === self?.session;
const items: MenuItem[] = [
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } },
{ label: 'Information', action: () => (ui.userInfo = u.session) }
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } }
];
if (isSelf) {
items.push(
@@ -41,6 +40,14 @@ export function userMenu(u: User): MenuItem[] {
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
{
label: 'Volume',
slider: {
value: voice.settings.userVolumes[userKey(u)] ?? 1, min: 0, max: 3, step: 0.05,
format: v => `${Math.round(v * 100)}%`,
oninput: v => voice.setUserVolume(u, v)
}
},
{ label: 'Set nickname...', action: () => nicknamePrompt(u) }
);
if (self && u.channelId !== self.channelId) {
@@ -63,6 +70,7 @@ export function userMenu(u: User): MenuItem[] {
}
items.push({ separator: true, label: '' }, { label: 'Copy name', action: () => navigator.clipboard.writeText(u.name) });
if (u.hash) items.push({ label: 'Copy certificate hash', action: () => navigator.clipboard.writeText(u.hash) });
items.push({ separator: true, label: '' }, { label: 'Information', action: () => (ui.userInfo = u.session) });
return items;
}
+8 -4
View File
@@ -1,6 +1,6 @@
import DOMPurify, { type Config } from 'dompurify';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube' | 'link';
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
@@ -108,13 +108,17 @@ export function renderIncoming(html: string, trustedHosts: string[]): { html: st
}
let url: URL;
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
if (!trustedHosts.includes(url.host.toLowerCase())) continue;
const trusted = trustedHosts.includes(url.host.toLowerCase());
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
const kind = mediaKind(name);
const kind = trusted ? mediaKind(name) : null;
if (kind) embeds.push({ kind, url: url.href, name });
// Other web links get a preview card (not for the upload host's own files)
else if (!trusted && /^https?:$/.test(url.protocol) && embeds.filter(e => e.kind === 'link').length < 2 && !embeds.some(e => e.url === url.href)) {
embeds.push({ kind: 'link', url: url.href, name: a.textContent ?? '' });
}
}
// The inline thumbnail is only there for vanilla clients; we show the real media instead
if (embeds.some(e => e.kind !== 'youtube')) frag.querySelectorAll('img').forEach(img => img.remove());
if (embeds.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'audio')) frag.querySelectorAll('img').forEach(img => img.remove());
const div = document.createElement('div');
div.append(frag);
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };
+2
View File
@@ -5,6 +5,8 @@ export interface MenuItem {
checked?: boolean;
disabled?: boolean;
separator?: boolean;
// Inline slider instead of a clickable item; the menu stays open while dragging
slider?: { value: number; min: number; max: number; step: number; format: (v: number) => string; oninput: (v: number) => void };
}
// One context menu for the whole app, opened at the pointer position
+11
View File
@@ -5,6 +5,7 @@ interface NativeApi {
describeCerts(ders: Uint8Array[]): Promise<CertDetails[]>;
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void;
linkPreview(url: string): Promise<LinkPreviewData | null>;
identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>;
@@ -23,6 +24,16 @@ interface NativeApi {
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
}
export interface LinkPreviewData {
url: string;
title: string;
description: string;
site: string;
image: string;
large: boolean;
color: string;
}
export interface IdentitySummary {
id: string;
name: string;
+23
View File
@@ -0,0 +1,23 @@
import { native, type LinkPreviewData } from './native.ts';
import { store } from './settings.svelte.ts';
// One request per URL and mode per app run
const cache = new Map<string, Promise<LinkPreviewData | null>>();
export function linkPreview(url: string): Promise<LinkPreviewData | null> {
const { linkPreviews, uploadHost, uploadKey } = store.settings;
const mode = linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews;
if (mode === 'off') return Promise.resolve(null);
const id = `${mode}|${url}`;
let p = cache.get(id);
if (!p) {
p = mode === 'direct'
? (native?.linkPreview(url) ?? Promise.resolve(null)).catch(() => null)
: fetch(`${uploadHost.replace(/\/+$/, '')}/api/chat/preview?url=${encodeURIComponent(url)}`, { headers: { 'X-API-Key': uploadKey } })
.then(r => r.json())
.then(d => (d?.success ? d as LinkPreviewData : null))
.catch(() => null);
cache.set(id, p);
}
return p;
}
+4 -1
View File
@@ -19,6 +19,8 @@ export interface Settings {
sendThumbnails: boolean; // include a small inline image for vanilla Mumble clients
compact: boolean;
youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail
// Link previews: fetched by the upload host (private), by this computer (reveals IP), or none
linkPreviews: 'host' | 'direct' | 'off';
}
const defaults: Settings = {
@@ -28,7 +30,8 @@ const defaults: Settings = {
embedHosts: [],
sendThumbnails: true,
compact: false,
youtube: 'click'
youtube: 'click',
linkPreviews: 'host'
};
class SettingsStore {
+1 -1
View File
@@ -48,7 +48,7 @@
<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} />
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if}
+5 -1
View File
@@ -2,9 +2,11 @@
import Icon from './Icon.svelte';
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import VoiceStage from './VoiceStage.svelte';
import { session } from '../lib/session.svelte.ts';
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
// showStage: voice tiles above the chat when the window is too narrow for the member list
let { onmenu, onpanel, showMenu, showStage = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean } = $props();
const isDm = $derived(session.view.startsWith('dm:'));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
@@ -24,6 +26,8 @@
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
</header>
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
+16
View File
@@ -2,6 +2,9 @@
import { menu } from '../lib/menu.svelte.ts';
let el: HTMLDivElement | undefined = $state();
// Live values of slider items while the menu is open
let sliderValues = $state<Record<number, number>>({});
$effect(() => { void menu.open; sliderValues = {}; });
let pos = $state({ x: 0, y: 0 });
// Keep the menu on screen near the pointer
@@ -21,6 +24,8 @@
if (e.key === 'Escape') menu.close();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
// Arrow keys adjust a focused slider instead of moving through the items
if ((document.activeElement as HTMLInputElement | null)?.type === 'range') return;
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();
@@ -36,6 +41,14 @@
{#each menu.open.items as item, i (i)}
{#if item.separator}
<div class="sep" role="separator"></div>
{:else if item.slider}
{@const sl = item.slider}
<div class="slider">
<span class="slider-label"><span>{item.label}</span><span>{sl.format(sliderValues[i] ?? sl.value)}</span></span>
<input type="range" min={sl.min} max={sl.max} step={sl.step} value={sl.value} aria-label={item.label}
oninput={e => { const v = Number(e.currentTarget.value); sliderValues[i] = v; sl.oninput(v); }}
ondblclick={() => { sliderValues[i] = 1; sl.oninput(1); }} />
</div>
{:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}>
@@ -61,4 +74,7 @@
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
.slider { padding: 6px 10px 8px; }
.slider-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
.slider input { width: 100%; padding: 0; border: 0; background: transparent; accent-color: var(--accent); margin-top: 4px; }
</style>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { linkPreview } from '../lib/preview.ts';
import type { LinkPreviewData } from '../lib/native.ts';
let { url }: { url: string } = $props();
let data = $state<LinkPreviewData | null>(null);
let imageOk = $state(true);
$effect(() => {
let alive = true;
linkPreview(url).then(d => { if (alive) data = d; });
return () => { alive = false; };
});
</script>
{#if data}
<article class="card" class:large={data.large && data.image && imageOk} style:--bar={data.color || 'var(--accent)'}>
<div class="text">
<span class="site">{data.site}</span>
<a class="title" href={url} target="_blank" rel="noopener noreferrer">{data.title || data.url}</a>
{#if data.description}<p class="desc">{data.description}</p>{/if}
</div>
{#if data.image && imageOk}
<a class="thumb" href={url} target="_blank" rel="noopener noreferrer" tabindex="-1" aria-hidden="true">
<img src={data.image} alt="" loading="lazy" referrerpolicy="no-referrer" onerror={() => (imageOk = false)} />
</a>
{/if}
</article>
{/if}
<style>
.card { display: flex; gap: 12px; max-width: min(520px, 100%); padding: 10px 12px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--bar); }
.card.large { flex-direction: column; }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.site { font-size: 12px; color: var(--text-dim); }
.title { font-weight: 600; color: var(--link); overflow-wrap: anywhere; }
.desc { margin: 0; font-size: 13px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.thumb { flex: none; width: 80px; height: 80px; }
.large .thumb { width: 100%; height: auto; }
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--bg-0); }
.large .thumb img { height: auto; max-height: 280px; }
</style>
+4 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Avatar from './Avatar.svelte';
import YouTubeEmbed from './YouTubeEmbed.svelte';
import LinkPreview from './LinkPreview.svelte';
import { store } from '../lib/settings.svelte.ts';
import type { ChatEntry } from '../lib/session.svelte.ts';
@@ -33,7 +34,9 @@
{/if}
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
{#each entry.embeds as m (m.url)}
{#if m.kind === 'youtube'}
{#if m.kind === 'link'}
<div class="embed"><LinkPreview url={m.url} /></div>
{:else 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">
+10 -21
View File
@@ -7,8 +7,8 @@
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 { userMenu, changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { renderIncoming } from '../lib/html.ts';
import RichEditor from './RichEditor.svelte';
@@ -94,7 +94,6 @@
<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>
@@ -139,15 +138,15 @@
<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>
<div class="who-text">
<h3>{store.displayName(user)}</h3>
<p class="where" title="Current channel"><Icon name="channel" size={14} /><span>{session.channel(user.channelId)?.name || 'Root'}</span></p>
{#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 user.userId != null}<span class="badge-reg registered">Registered</span>{/if}
{#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}
@@ -160,17 +159,6 @@
{#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>
@@ -223,16 +211,17 @@
.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; }
.who-text { min-width: 0; }
.where { display: flex; align-items: center; gap: 5px; margin: 3px 0 0; font-size: 13px; color: var(--text-dim); min-width: 0; }
.where :global(svg) { flex: none; }
.where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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; }
.badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 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); }
+7
View File
@@ -75,6 +75,13 @@
<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>
<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="click">Player card, loads from YouTube when clicked</option>
+7 -5
View File
@@ -18,6 +18,8 @@
let stats = $state<any>(null);
let certs = $state<CertDetails[]>([]);
let updated = $state(0);
// Seconds since the last answer, so online and idle times tick between answers
let now = $state(Date.now());
const client = session.client;
const user = $derived(session.user(who));
@@ -32,8 +34,10 @@
}
});
client?.requestUserStats(who);
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
// Live: ask every second (UserStats is not rate limited by the server)
const timer = setInterval(() => { now = Date.now(); client?.requestUserStats(who, true); }, 1000);
onDestroy(() => { off?.(); clearInterval(timer); });
const since = $derived(updated ? Math.max(0, Math.floor((now - updated) / 1000)) : 0);
function duration(secs?: number): string {
if (secs == null) return 'unknown';
@@ -73,8 +77,8 @@
{: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>Online for</dt><dd>{duration(stats.onlinesecs != null ? stats.onlinesecs + since : undefined)}</dd>
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs + since)}</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>
@@ -124,7 +128,6 @@
{/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>
@@ -144,6 +147,5 @@
.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>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
// People in your voice channel as tiles, for windows too narrow for the member list
const self = $derived(session.self);
const channel = $derived(self ? session.channel(self.channelId) : undefined);
const people = $derived(self ? session.usersIn(self.channelId) : []);
let collapsed = $state(load());
function load(): boolean {
try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; }
}
function toggle() {
collapsed = !collapsed;
try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ }
}
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
</script>
{#if self}
<section class="stage" aria-label="Voice channel">
<header>
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
<span class="name">{channel?.name || 'Root'}</span>
<span class="count">{people.length}</span>
{#if collapsed}
<span class="mini" aria-hidden="true">
{#each people.slice(0, 8) as u (u.session)}<Avatar name={u.name} size={18} speaking={talking(u.session)} />{/each}
</span>
{/if}
</button>
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!self.selfMute)}>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={self.selfDeaf} aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'} title={self.selfDeaf ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!self.selfDeaf)}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
</button>
</header>
{#if !collapsed}
<ul class="tiles">
{#each people as u (u.session)}
<li>
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session}
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
<span class="who">{store.displayName(u)}</span>
{#if u.deaf || u.selfDeaf}
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
{:else if u.mute || u.selfMute || u.suppress}
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<style>
.stage { flex: none; border-bottom: 1px solid var(--line); background: var(--bg-1); }
header { display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; }
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
.title:hover { background: var(--bg-hover); }
.caret { flex: none; color: var(--text-faint); }
.caret.collapsed { transform: rotate(-90deg); }
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.count { font-size: 12px; color: var(--text-faint); }
.mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; }
.tiles { list-style: none; margin: 0; padding: 0 10px 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 6px;
max-height: 40vh; overflow-y: auto; }
.tile { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px 8px;
background: var(--bg-2); border: 1px solid var(--line); transition: border-color 80ms, box-shadow 80ms; }
.tile:hover { background: var(--bg-hover); }
.tile.talking { border-color: var(--speaking); box-shadow: inset 0 0 0 1px var(--speaking); }
.who { max-width: 100%; font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile.self .who { color: var(--text); font-weight: 600; }
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
.badge.admin { color: var(--danger); }
</style>