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