Merge pull request 'Add link previews, voice tiles and a menu volume slider; tidy the profile' (#1) from link-previews-voice-tiles into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-30 22:06:15 +00:00
29 changed files with 541 additions and 60 deletions
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to mumh5. Versions follow the `version` in `package.json`.
## Unreleased
### Added
- Link previews in chat: title, description and image for web links (up to two per message). By default they are fetched by your f0ckm upload host, so the linked sites never see your IP address. Can be switched to "fetched by this computer" or off in Settings, Chat and files.
- Voice tiles on small windows: when the window is too narrow for the member list, the people in your voice channel appear as tiles above the chat, light up while they talk, and keep mute and deafen at hand. The tiles can be collapsed.
- Per-person volume slider (0 to 300%) directly in the user context menu. Double-click resets it to 100%.
### Changed
- Profile panel: the current channel sits on its own line under the name, and the badge row wraps on narrow panels.
- Profile panel: guests no longer get a "Guest" badge; registered users keep the "Registered" badge.
- Profile panel: volume, "mute for me" and nickname moved to the user context menu, which has them for every place a user appears.
- User context menu: "Information" moved to the end of the menu.
- User information dialog updates every second, so online and idle times tick live.
- Channel tree: channels no longer show an icon in front of the name; only channels you cannot enter show a lock.
- Channel details header without the channel icon.
### Fixed
- Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages.
## 0.1.0
- Initial version.
+6 -1
View File
@@ -31,6 +31,7 @@ mumh5 keeps the foundation and replaces the experience.
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges |
| Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one |
| YouTube links | Plain links | Click-to-play player (privacy mode), optional thumbnails |
| Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP |
| Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own |
| Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit |
| Identities | One certificate per installation | Several identities, a different one per server if you like; imports your desktop certificate |
@@ -51,6 +52,7 @@ mumh5 keeps the foundation and replaces the experience.
- Microphone and output device selection, input and output volume
- Per-person volume (0 to 300%) and "mute for me"
- Speaking indicators in the channel tree, member list and your own panel
- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand
- Bitrate automatically limited to what the server allows
- Right-click the mute or deafen button for quick device and volume options
- Hear-yourself microphone test
@@ -73,6 +75,7 @@ mumh5 keeps the foundation and replaces the experience.
- File sharing: drag and drop, paste, or the attach button, with upload progress
- Inline images, video and audio players for shared media
- YouTube links as click-to-play players
- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it
- Incoming HTML is sanitized; remote images from unknown hosts are never loaded
- Messages stay compatible with the desktop client, including picture previews
@@ -127,7 +130,9 @@ f0ckm keeps these uploads separate from its imageboard, respects its own list of
```
2. In mumh5, open **Settings, Chat and files**, enter the f0ckm address and the key, and press **Test key**.
Without an upload host, mumh5 still sends images, scaled to fit the server's limit, the same way the desktop client does.
The same host also fetches link previews for you, so the sites people link to never see your IP address.
Without an upload host, mumh5 still sends images, scaled to fit the server's limit, the same way the desktop client does. Link previews can then be fetched directly by your computer if you enable it.
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 117 KiB

+150
View File
@@ -0,0 +1,150 @@
import http from 'node:http';
import https from 'node:https';
import dns from 'node:dns';
import net from 'node:net';
// Link preview fetched from this computer ("direct" mode, reveals the user's IP to the linked
// site). Same safeguards as the f0ckm endpoint: public addresses only, redirects re-checked,
// size and time limits.
export interface LinkPreview {
url: string;
title: string;
description: string;
site: string;
image: string;
large: boolean;
color: string;
}
const TIMEOUT_MS = 6000;
const MAX_HTML = 768 * 1024;
const MAX_REDIRECTS = 4;
const cache = new Map<string, { value: LinkPreview | null; expires: number }>();
function isPrivateV4(ip: string): boolean {
const [a, b] = ip.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || a >= 224 ||
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
(a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
}
export function isPrivateAddress(ip: string): boolean {
if (net.isIPv4(ip)) return isPrivateV4(ip);
const v6 = ip.toLowerCase();
const mapped = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
if (mapped) return isPrivateV4(mapped[1]);
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
}
const safeLookup: net.LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return (callback as any)(err);
const ok = addresses.filter(a => !isPrivateAddress(a.address));
if (!ok.length) return (callback as any)(new Error('Refusing private address'));
if ((options as dns.LookupOptions)?.all) return (callback as any)(null, ok);
(callback as any)(null, ok[0].address, ok[0].family);
});
};
function safeGet(rawUrl: string, hops = 0): Promise<http.IncomingMessage & { finalUrl: string }> {
return new Promise((resolve, reject) => {
let url: URL;
try { url = new URL(rawUrl); } catch { return reject(new Error('Invalid URL')); }
if (!/^https?:$/.test(url.protocol)) return reject(new Error('Unsupported protocol'));
const host = url.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host) && isPrivateAddress(host)) return reject(new Error('Refusing private address'));
const lib = url.protocol === 'https:' ? https : http;
const req = lib.get(url, {
lookup: safeLookup,
timeout: TIMEOUT_MS,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; mumh5-linkpreview/1.0)', 'Accept': 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' }
}, res => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (hops >= MAX_REDIRECTS) return reject(new Error('Too many redirects'));
return resolve(safeGet(new URL(res.headers.location, url).href, hops + 1));
}
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); }
resolve(Object.assign(res, { finalUrl: url.href }));
});
req.on('timeout', () => req.destroy(new Error('Timed out')));
req.on('error', reject);
});
}
function readHead(res: http.IncomingMessage): Promise<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];
let size = 0;
const timer = setTimeout(() => res.destroy(), TIMEOUT_MS);
const done = () => { clearTimeout(timer); resolve(Buffer.concat(chunks).subarray(0, MAX_HTML)); };
res.on('data', (c: Buffer) => {
chunks.push(c);
size += c.length;
if (size > MAX_HTML || c.toString('latin1').toLowerCase().includes('</head>')) res.destroy();
});
res.on('end', done);
res.on('close', done);
res.on('error', done);
});
}
const decodeEntities = (s: string) => s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
const clean = (s: string | undefined, max: number) => {
if (!s) return '';
const t = decodeEntities(s).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
export function parsePreview(html: string, pageUrl: string): Omit<LinkPreview, 'url'> {
const head = html.split(/<\/head>/i)[0];
const meta: Record<string, string> = {};
for (const [, attrs] of head.matchAll(/<meta\s+([^>]+?)\/?>/gi)) {
const a: Record<string, string> = {};
for (const m of attrs.matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
}
const key = (a.property || a.name || '').toLowerCase();
if (key && a.content !== undefined && !(key in meta)) meta[key] = a.content;
}
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1];
const abs = (u: string) => { try { const x = new URL(decodeEntities(u), pageUrl); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
return {
title: clean(meta['og:title'] || meta['twitter:title'] || titleTag, 200),
description: clean(meta['og:description'] || meta['twitter:description'] || meta['description'], 400),
site: clean(meta['og:site_name'] || new URL(pageUrl).hostname.replace(/^www\./, ''), 80),
image: abs(meta['og:image:secure_url'] || meta['og:image'] || meta['twitter:image'] || meta['twitter:image:src'] || ''),
large: meta['twitter:card'] === 'summary_large_image' || Number(meta['og:image:width']) >= 600,
color: /^#[0-9a-f]{3,8}$/i.test(meta['theme-color'] ?? '') ? meta['theme-color'] : ''
};
}
export async function fetchLinkPreview(url: string): Promise<LinkPreview | null> {
const hit = cache.get(url);
if (hit && hit.expires > Date.now()) return hit.value;
let value: LinkPreview | null = null;
try {
const res = await safeGet(url);
const type = String(res.headers['content-type'] ?? '');
if (/text\/html|application\/xhtml/i.test(type)) {
const charset = /charset=([\w-]+)/i.exec(type)?.[1]?.toLowerCase() || 'utf-8';
const body = await readHead(res);
let html: string;
try { html = new TextDecoder(charset).decode(body); } catch { html = body.toString('utf8'); }
const p = parsePreview(html, res.finalUrl);
if (p.title || p.description) value = { ...p, url: res.finalUrl };
} else {
res.resume();
}
} catch { /* no preview */ }
cache.set(url, { value, expires: Date.now() + (value ? 6 * 3600e3 : 30 * 60e3) });
if (cache.size > 1000) cache.delete(cache.keys().next().value!);
return value;
}
+3
View File
@@ -5,6 +5,7 @@ import path from 'node:path';
import { IdentityStore } from './identity-store.ts';
import { describeCert } from './certs.ts';
import * as tray from './tray.ts';
import { fetchLinkPreview } from './link-preview.ts';
import { openTls } from './tls-transport.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL;
@@ -74,6 +75,8 @@ ipcMain.on('mumble:close', (e, connId: string) => {
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048)));
ipcMain.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
ipcMain.handle('platform:info', () => ({ os: process.platform, osVersion: os.release() }));
+1
View File
@@ -12,6 +12,7 @@ for (const key of Object.keys(listeners) as (keyof typeof listeners)[]) {
contextBridge.exposeInMainWorld('mumh5Native', {
platformInfo: () => ipcRenderer.invoke('platform:info'),
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
linkPreview: (url: string) => ipcRenderer.invoke('preview:fetch', url),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: {
+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>
+58 -3
View File
@@ -276,6 +276,31 @@ try {
await page.waitForFunction(() => (document.querySelector('.embed img') as HTMLImageElement)?.naturalWidth === 400);
console.log('ok: upload posted with vanilla thumbnail, embed loads for mumh5');
// Link previews through the upload host: fetched and image-proxied by f0ckm (needs internet)
bob.sendText({ channels: [0] }, '<a href="https://github.com/mumble-voip/mumble">https://github.com/mumble-voip/mumble</a>');
const gh = page.locator('.msg', { hasText: 'github.com/mumble-voip' }).locator('.card');
await gh.locator('.title', { hasText: /mumble/i }).waitFor({ timeout: 15000 });
await gh.locator('.site', { hasText: 'GitHub' }).waitFor();
const ghImg = gh.locator('img');
assert.ok((await ghImg.getAttribute('src'))!.startsWith(`${process.env.UPLOAD_HOST}/api/chat/preview/image?`), 'image proxied by the upload host');
await page.waitForFunction(() => ((document.querySelector('.card img') as HTMLImageElement)?.naturalWidth ?? 0) > 0, null, { timeout: 15000 });
console.log('ok: link preview through the upload host');
// Direct mode: this computer fetches the preview itself
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Link previews').selectOption('direct');
await page.getByRole('button', { name: 'Done', exact: true }).click();
bob.sendText({ channels: [0] }, '<a href="https://www.mumble.info/">https://www.mumble.info/</a>');
const direct = page.locator('.msg', { hasText: 'www.mumble.info' }).locator('.card');
await direct.locator('.title').waitFor({ timeout: 15000 });
assert.ok(!(await direct.locator('img').getAttribute('src').catch(() => ''))?.includes('/api/chat/preview/image'), 'direct mode does not use the proxy');
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Link previews').selectOption('host');
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: link preview fetched directly');
// Types the upload host does not allow are refused before uploading
const txt = path.join(userData, 'notes.txt');
writeFileSync(txt, 'plain text');
@@ -330,6 +355,12 @@ try {
for (const item of ['View profile', 'Send message', 'Server mute', 'Kick...', 'Ban...']) {
await page.getByRole('menuitem', { name: item }).waitFor();
}
// Per-person volume lives in the menu
await page.getByRole('slider', { name: 'Volume' }).fill('1.5');
await page.locator('.menu .slider-label', { hasText: '150%' }).waitFor();
await page.keyboard.press('Escape');
await page.locator('.sidebar .user', { hasText: 'bob' }).click({ button: 'right' });
assert.equal(await page.getByRole('slider', { name: 'Volume' }).inputValue(), '1.5', 'volume remembered');
await page.getByRole('menuitem', { name: 'View profile' }).click();
await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
console.log('ok: user context menu and profile');
@@ -341,6 +372,11 @@ try {
await info.getByText('mumh5 0.1.0').waitFor();
await info.getByText('linux', { exact: false }).first().waitFor();
await info.getByText('Online for').waitFor();
// Live: the online time advances while the dialog is open
const onlineText = () => info.locator('dt', { hasText: 'Online for' }).locator('xpath=following-sibling::dd[1]').textContent();
const t1 = await onlineText();
await page.waitForTimeout(2200);
assert.notEqual(await onlineText(), t1, 'online time ticks live');
await info.getByRole('button', { name: 'View certificate' }).click();
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByText('alice', { exact: true }).first().waitFor();
await page.getByRole('dialog', { name: /^Certificate of alice/ }).getByRole('button', { name: 'Close', exact: true }).last().click();
@@ -436,7 +472,7 @@ try {
writeFileSync(big, testPng(1600, 1200));
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
await page.locator('.chat .composer input[type=file]').setInputFiles(big);
const inline = await within(gotInline, 'inline image');
const inline = await within(gotInline, 'inline image', 15000);
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
console.log(`ok: inline image without upload host (${inline.length} chars)`);
@@ -498,6 +534,19 @@ try {
await page.locator('.rail .tile[title^="Test Server"] .unread').waitFor({ state: 'detached' });
console.log('ok: voice returns when the other server disconnects');
// Narrow window: voice channel as tiles, speaking lights up the tile
await page.setViewportSize({ width: 420, height: 800 });
const stage = page.getByRole('region', { name: 'Voice channel' });
const bobTile = stage.locator('.tile', { hasText: 'bob' });
await bobTile.waitFor();
const echo2 = packets.map((p, i) => encodeVoice({ target: 0, frame: 5000 + i * 2, opus: p.opus, last: i === packets.length - 1 }, bob.protobufVoice));
const pump2 = setInterval(() => { const pk = echo2.shift(); if (pk) bob.sendVoiceTunnel(pk); }, 20);
await page.locator('.stage .tile.talking', { hasText: 'bob' }).waitFor({ timeout: 3000 });
clearInterval(pump2);
await page.setViewportSize({ width: 1280, height: 800 });
await stage.waitFor({ state: 'detached' }); // wide layouts use the member list instead
console.log('ok: voice tiles on small windows show who is talking');
// Change name: Mumble needs a reconnect, the app does it and remembers the name.
// Unique per run: a reused test server may still have an earlier run's name registered.
const newName = `alice${Date.now() % 100000}`;
@@ -512,9 +561,10 @@ try {
}), 'bob sees the new name');
console.log('ok: name changed by reconnecting');
// Self-registration: guest badge becomes registered
// Self-registration: a guest has no badge and a Register button; afterwards the Registered badge shows
await page.locator('.me .profile').click();
await page.locator('.panel .badge-reg', { hasText: 'Guest' }).waitFor();
await page.locator('.panel .register').waitFor();
if (await page.locator('.panel .badge-reg').count()) throw new Error('guest profile shows a badge');
await page.locator('.panel .register').click();
await page.locator('.panel .badge-reg.registered', { hasText: 'Registered' }).waitFor();
await page.locator('.sidebar .user', { hasText: newName }).locator('.reg').waitFor();
@@ -546,6 +596,11 @@ try {
await page.screenshot({ path: path.join(shots, 'profile.png') });
await page.setViewportSize({ width: 400, height: 780 });
await page.waitForTimeout(200);
// Small windows show the voice channel as tiles above the chat
const stage = page.getByRole('region', { name: 'Voice channel' });
await stage.locator('.tile', { hasText: 'bob' }).waitFor();
await stage.getByRole('button', { name: /^(Mute|Unmute)$/ }).waitFor();
await page.screenshot({ path: path.join(shots, 'narrow-stage.png') });
await page.getByLabel('Open channels').click();
await page.waitForTimeout(200);
await page.screenshot({ path: path.join(shots, 'narrow-drawer.png') });
+8 -2
View File
@@ -62,7 +62,7 @@ const lena = await headless('lena');
sarah.joinChannel(lobby);
max.joinChannel(lobby);
await wait(300);
jonas.joinChannel(byName(jonas, 'Minecraft').id);
jonas.joinChannel(lobby);
lena.joinChannel(byName(lena, 'Music').id);
await wait(1200);
lena.setSelfMute(true);
@@ -89,6 +89,7 @@ await page.getByRole('button', { name: 'Done' }).click();
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('YouTube links').selectOption('thumbnail');
await page.getByLabel('Link previews').selectOption('direct');
await page.getByRole('tab', { name: 'Voice' }).click();
await page.getByRole('radio', { name: /Always on/ }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
@@ -108,12 +109,17 @@ sarah.sendText({ channels: [lobby] }, 'anyone up for a round later?');
await wait(1200);
max.sendText({ channels: [lobby] }, 'yes, after dinner. 8pm?');
await wait(1200);
jonas.sendText({ channels: [lobby] }, 'btw this is what we are using: <a href="https://github.com/mumble-voip/mumble">https://github.com/mumble-voip/mumble</a>');
await wait(1200);
await page.getByLabel('Message').fill('works for me. meanwhile, a classic: https://www.youtube.com/watch?v=dQw4w9WgXcQ');
await page.getByLabel('Message').press('Enter');
await wait(1200);
sarah.sendText({ channels: [lobby] }, '<b>no.</b> not again <i>(ok it is a good song)</i>');
await wait(3000);
await page.locator('.card img').first().waitFor({ timeout: 15000 }).catch(() => {});
await wait(1000);
// Show the whole conversation
await page.locator('.chat .list').evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.screenshot({ path: path.join(out, 'main.png') });
await page.locator('.sidebar .user', { hasText: 'sarah' }).click();
+22
View File
@@ -139,3 +139,25 @@ test('voice packets are relayed through the TCP tunnel in the negotiated format'
}
});
test('irregular bursts match the server bucket (whole-second leaks)', { skip: !target }, async () => {
const alice = await connect('pace' + Date.now() % 1000);
const bob = await connect('paceB' + Date.now() % 1000);
try {
const got: string[] = [];
const total = 11;
const all = until<void>(res => bob.on('text', m => { got.push(m.html); if (got.length === total) res(); }), 25000);
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
// Gaps that do not line up with whole seconds used to make the client overestimate
const gaps = [0, 0, 0, 0, 0, 1900, 300, 300, 1400, 0, 0];
for (let i = 0; i < total; i++) {
await sleep(gaps[i]);
alice.sendText({ channels: [0] }, `p${i}`);
}
await all;
assert.deepEqual(got, gaps.map((_, i) => `p${i}`));
} finally {
alice.disconnect();
bob.disconnect();
}
});