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
+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();
}
});