Add server information, an always-visible mute bar and public list actions

- Public servers: connect arrow and add button per row, sortable column headers
- Mute and deafen stay available while disconnected; the choice carries to the next connect
- Server information dialog, opened from a button next to mute
- Stacked layout: mute bar on top, without the profile
- Settings that need the upload host are disabled until it is set; link previews show the mode in effect
- YouTube links are plain links by default
- Composer has a border and matches the sidebar bar height
- Regenerate README screenshots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 18:40:51 +02:00
co-authored by Claude Opus 5.5
parent 2c7a94441d
commit dc9e6696c9
23 changed files with 259 additions and 51 deletions
+4 -4
View File
@@ -20,7 +20,7 @@ mumh5 keeps the foundation and replaces the experience.
- **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are. - **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are.
- **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client. - **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client.
- **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are. - **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are.
- **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube stays click-to-play. - **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube links stay plain links unless you turn on the click-to-play player.
- **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens. - **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens.
### mumh5 compared to the Mumble desktop client ### mumh5 compared to the Mumble desktop client
@@ -30,7 +30,7 @@ mumh5 keeps the foundation and replaces the experience.
| Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client | | Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client |
| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges | | 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 | | 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 | | YouTube links | Plain links | Plain links by default; optional click-to-play player (privacy mode) and thumbnails |
| Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP | | 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 | | 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 | | Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit |
@@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience.
- Messages grouped by author, direct messages, unread badges - Messages grouped by author, direct messages, unread badges
- File sharing: drag and drop, paste, or the attach button, with upload progress - File sharing: drag and drop, paste, or the attach button, with upload progress
- Inline images, video and audio players for shared media - Inline images, video and audio players for shared media
- YouTube links as click-to-play players - YouTube links as click-to-play players (off by default)
- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it - 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 - Incoming HTML is sanitized; remote images from unknown hosts are never loaded
- Messages stay compatible with the desktop client, including picture previews - Messages stay compatible with the desktop client, including picture previews
@@ -169,7 +169,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
- **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up. - **Your identity is a certificate** that stays on your computer, stored with owner-only permissions. mumh5 reminds you to back it up.
- **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you. - **Server certificates are pinned** the first time you connect. If one changes, mumh5 stops before sending your password and asks you.
- **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube is click-to-play in privacy mode. - **Chats don't phone home.** Media is only loaded from your upload host and hosts you add yourself. YouTube players are off by default and click-to-play in privacy mode when enabled.
- **Strict content security policy** in the app, and all message HTML is sanitized. - **Strict content security policy** in the app, and all message HTML is sanitized.
--- ---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 126 KiB

+2
View File
@@ -14,6 +14,7 @@
import UserInfoDialog from './ui/UserInfoDialog.svelte'; import UserInfoDialog from './ui/UserInfoDialog.svelte';
import ChannelDialog from './ui/ChannelDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte'; import PublicServers from './ui/PublicServers.svelte';
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
import ResizeHandle from './ui/ResizeHandle.svelte'; import ResizeHandle from './ui/ResizeHandle.svelte';
import { identities } from './lib/identities.svelte.ts'; import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts'; import { session } from './lib/session.svelte.ts';
@@ -115,6 +116,7 @@
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if} {#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if} {#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if}
{#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if} {#if ui.publicServers}<PublicServers onclose={() => (ui.publicServers = false)} />{/if}
{#if ui.serverInfo && session.status === 'connected'}<ServerInfoDialog onclose={() => (ui.serverInfo = false)} />{/if}
{#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.settingsOpen}<SettingsDialog tab={ui.settingsTab} onclose={() => { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if}
{#if ui.quickAudio}<QuickAudio />{/if} {#if ui.quickAudio}<QuickAudio />{/if}
{#if ui.userInfo != null && session.status === 'connected'} {#if ui.userInfo != null && session.status === 'connected'}
+2
View File
@@ -6,6 +6,8 @@ export const ICON_PATHS: Record<string, string> = {
'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18', 'headphones-off': 'M3 14v-2a9 9 0 0 1 18 0v2M21 14v7h-4v-7zM3 14v7h4v-7zM3 3l18 18',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
plus: 'M12 5v14M5 12h14', plus: 'M12 5v14M5 12h14',
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 11v6M12 7h.01',
'arrow-right': 'M5 12h14M13 6l6 6-6 6',
paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5', paperclip: 'M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5',
hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18',
channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7', channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7',
+9 -2
View File
@@ -4,9 +4,16 @@ import { store } from './settings.svelte.ts';
// One request per URL and mode per app run // One request per URL and mode per app run
const cache = new Map<string, Promise<LinkPreviewData | null>>(); const cache = new Map<string, Promise<LinkPreviewData | null>>();
export function linkPreview(url: string): Promise<LinkPreviewData | null> { // The mode in effect: previews through the upload host need its key, without one they are off.
// Never falls back to fetching directly, that would show linked sites the user's IP.
export function previewMode(): 'host' | 'direct' | 'off' {
const { linkPreviews, uploadHost, uploadKey } = store.settings; const { linkPreviews, uploadHost, uploadKey } = store.settings;
const mode = linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews; return linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews;
}
export function linkPreview(url: string): Promise<LinkPreviewData | null> {
const { uploadHost, uploadKey } = store.settings;
const mode = previewMode();
if (mode === 'off') return Promise.resolve(null); if (mode === 'off') return Promise.resolve(null);
const id = `${mode}|${url}`; const id = `${mode}|${url}`;
let p = cache.get(id); let p = cache.get(id);
+32 -4
View File
@@ -161,6 +161,14 @@ export class Session {
this.expanded = all; this.expanded = all;
} }
// What the server told us about itself, for the server information dialog
get serverInfo() {
void this.tick;
const c = this.client;
if (!c) return null;
return { version: c.serverVersion, maxBandwidth: c.maxBandwidth, config: { ...c.config }, users: c.users.size, channels: c.channels.size };
}
// How voice travels right now // How voice travels right now
get voiceTransport(): { udp: boolean; rtt: number } { get voiceTransport(): { udp: boolean; rtt: number } {
void this.tick; void this.tick;
@@ -663,8 +671,17 @@ export class Session {
// ─── Voice state and moderation ───────────────────────────────────────────── // ─── Voice state and moderation ─────────────────────────────────────────────
setSelfMute(v: boolean) { this.client?.setSelfMute(v); } // The choice is remembered, so it also works while disconnected and carries to the next connect
setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); } setSelfMute(v: boolean) {
sessions.mute = v;
if (!v) sessions.deaf = false;
this.client?.setSelfMute(v);
}
setSelfDeaf(v: boolean) {
sessions.deaf = v;
if (v) sessions.mute = true;
this.client?.setSelfDeaf(v);
}
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); } kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); } setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
@@ -683,6 +700,10 @@ class Sessions {
activeId = $state<string | null>(null); activeId = $state<string | null>(null);
voiceId = $state<string | null>(null); voiceId = $state<string | null>(null);
private idle = new Session(); private idle = new Session();
// Self mute/deaf as the user last chose it, like the desktop client: shown and toggled
// while disconnected and applied to servers when they connect
mute = $state(false);
deaf = $state(false);
// Self mute/deaf before we deafened a background server, to restore later // Self mute/deaf before we deafened a background server, to restore later
private saved = new Map<Session, { mute: boolean; deaf: boolean }>(); private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
@@ -725,8 +746,15 @@ class Sessions {
} }
onSynced(s: Session): void { onSynced(s: Session): void {
if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true); const want = { mute: this.mute, deaf: this.deaf };
else this.toBackground(s); if (!this.voiceId || !this.voiceSession?.client) {
this.setVoice(s, true);
if (want.mute) s.client?.send('UserState', { self_mute: true, self_deaf: want.deaf });
} else {
this.toBackground(s);
// A fresh login is unmuted; restore the user's choice when voice comes here
if (this.saved.has(s)) this.saved.set(s, want);
}
} }
onClosed(s: Session): void { onClosed(s: Session): void {
+1 -1
View File
@@ -46,7 +46,7 @@ const defaults: Settings = {
embedHosts: [], embedHosts: [],
sendThumbnails: true, sendThumbnails: true,
compact: false, compact: false,
youtube: 'click', youtube: 'off',
linkPreviews: 'host', linkPreviews: 'host',
theme: 'dark', theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
+1
View File
@@ -27,6 +27,7 @@ class Ui {
quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null);
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice'); settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice');
publicServers = $state(false); publicServers = $state(false);
serverInfo = $state(false);
// Add-server dialog, optionally prefilled (from the public list) // Add-server dialog, optionally prefilled (from the public list)
addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); addServer = $state<{ host?: string; port?: number; label?: string } | null>(null);
// Channel dialog: create a subchannel of `parent`, or edit channel `id` // Channel dialog: create a subchannel of `parent`, or edit channel `id`
+2
View File
@@ -164,6 +164,8 @@
box-shadow: var(--sunken); box-shadow: var(--sunken);
} }
:root[data-theme="win95"] .composer .box textarea { box-shadow: none; } :root[data-theme="win95"] .composer .box textarea { box-shadow: none; }
/* Raised frame in the window face color around the sunken input */
:root[data-theme="win95"] .composer { border: 2px outset #c0c0c0; }
:root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; } :root[data-theme="win95"] .rich .area, :root[data-theme="win95"] .rich textarea { box-shadow: none; }
:root[data-theme="win95"] input[type=range] { accent-color: #000080; } :root[data-theme="win95"] input[type=range] { accent-color: #000080; }
+4 -4
View File
@@ -132,11 +132,11 @@
</div> </div>
<style> <style>
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */ /* Same height as the bar at the bottom of the sidebar, buttons centered like there; grows for longer messages */
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); } .composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 0; background: var(--bg-0); border: 1px solid var(--line); }
.frame { background: var(--bg-3); } .frame { flex: 1; display: flex; flex-direction: column; background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); } .frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: var(--footer-h); padding: 6px 4px; } .box { flex: 1; display: flex; align-items: center; gap: 4px; padding: 0 8px; }
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; } .input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; } .input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
.input :global(img) { max-height: 120px; } .input :global(img) { max-height: 120px; }
+55 -9
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import Icon from './Icon.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts'; import { native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
let { onclose }: { onclose: () => void } = $props(); let { onclose }: { onclose: () => void } = $props();
@@ -12,7 +14,10 @@
let error = $state(''); let error = $state('');
let search = $state(''); let search = $state('');
let continent = $state(''); let continent = $state('');
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users'); type Sort = 'users' | 'ping' | 'name' | 'country';
let sort = $state<Sort>('users');
// Each column has a natural order (most people, lowest ping, A to Z); a second click flips it
let reversed = $state(false);
let pings = $state<Record<string, PingResult | null>>({}); let pings = $state<Record<string, PingResult | null>>({});
let alive = true; let alive = true;
// Ping queue; the generation drops answers from a previous list // Ping queue; the generation drops answers from a previous list
@@ -53,14 +58,24 @@
const list = servers.filter(s => (!continent || s.continent === continent) && const list = servers.filter(s => (!continent || s.continent === continent) &&
(!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q))); (!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q)));
const p = (s: PublicServer) => pings[key(s)]; const p = (s: PublicServer) => pings[key(s)];
const dir = reversed ? -1 : 1;
return list.sort((a, b) => { return list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name); if (sort === 'name') return dir * a.name.localeCompare(b.name);
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name); if (sort === 'country') return dir * a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9); // Servers that have not answered stay at the bottom in both directions
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1); const pa = p(a), pb = p(b);
if (!pa || !pb) return pa ? -1 : pb ? 1 : 0;
return dir * (sort === 'ping' ? pa.ping - pb.ping : pb.users - pa.users);
}).slice(0, 300); }).slice(0, 300);
}); });
function sortBy(col: Sort) {
reversed = sort === col && !reversed;
sort = col;
}
// Most people first counts as descending, the other columns start ascending
const ariaSort = (col: Sort) => sort !== col ? 'none' : reversed === (col === 'users') ? 'ascending' : 'descending';
// Ping what the filter shows, a few at a time; each answer fills in users and latency // Ping what the filter shows, a few at a time; each answer fills in users and latency
function pingVisible() { function pingVisible() {
queue = servers.filter(s => !(key(s) in pings)); queue = servers.filter(s => !(key(s) in pings));
@@ -80,6 +95,18 @@
}); });
} }
// Connects right away with the name used last; the server is saved so it shows in the rail.
// Without a name to reuse, the add dialog asks for one.
function connect(s: PublicServer) {
const saved = store.servers.find(x => x.host === s.host && x.port === s.port);
const username = store.servers.at(-1)?.username;
if (!saved && !username) return add(s);
const server = saved ?? { id: crypto.randomUUID(), label: s.name || s.host, host: s.host, port: s.port, username: username!, password: '' };
if (!saved) store.upsertServer(server);
sessions.open(server);
onclose();
}
function add(s: PublicServer) { function add(s: PublicServer) {
ui.addServer = { host: s.host, port: s.port, label: s.name }; ui.addServer = { host: s.host, port: s.port, label: s.name };
onclose(); onclose();
@@ -106,7 +133,7 @@
<option value="">All continents</option> <option value="">All continents</option>
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each} {#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
</select> </select>
<select bind:value={sort} aria-label="Sort by"> <select bind:value={sort} onchange={() => (reversed = false)} aria-label="Sort by">
<option value="users">Most people</option> <option value="users">Most people</option>
<option value="ping">Lowest ping</option> <option value="ping">Lowest ping</option>
<option value="name">Name</option> <option value="name">Name</option>
@@ -121,7 +148,14 @@
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div> <div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
{:else} {:else}
<table> <table>
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead> <thead><tr>
{#each [['name', 'Server'], ['country', 'Country'], ['users', 'People'], ['ping', 'Ping']] as const as [col, text] (col)}
<th class:num={col === 'users' || col === 'ping'} aria-sort={ariaSort(col)}>
<button class="sort" class:on={sort === col} title="Sort by {text.toLowerCase()}" onclick={() => sortBy(col)}>{text}<span class="caret" class:up={ariaSort(col) === 'ascending'}></span></button>
</th>
{/each}
<th></th>
</tr></thead>
<tbody> <tbody>
{#each shown as s (key(s))} {#each shown as s (key(s))}
{@const p = pings[key(s)]} {@const p = pings[key(s)]}
@@ -134,7 +168,12 @@
<td>{s.country}</td> <td>{s.country}</td>
<td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td> <td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td>
<td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td> <td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td>
<td class="act"><button class="btn" onclick={() => add(s)}>Add</button></td> <td class="act">
<span class="acts">
<button class="icon-btn" title="Add to your servers" aria-label="Add" onclick={() => add(s)}><Icon name="plus" size={18} /></button>
<button class="icon-btn go" title="Connect" aria-label="Connect" onclick={() => connect(s)}><Icon name="arrow-right" size={18} /></button>
</span>
</td>
</tr> </tr>
{:else} {:else}
<tr><td colspan="5" class="dim">No servers match.</td></tr> <tr><td colspan="5" class="dim">No servers match.</td></tr>
@@ -157,6 +196,12 @@
.bar select { width: auto; flex: 0 0 auto; } .bar select { width: auto; flex: 0 0 auto; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; } table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; }
th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); } th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); }
.sort { font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; }
.sort:hover, .sort.on { color: var(--text); }
/* Triangle from borders; only the active column shows it */
.caret { border: 4px solid transparent; border-top-color: currentColor; margin-top: 4px; visibility: hidden; }
.caret.up { border-top-color: transparent; border-bottom-color: currentColor; margin-top: -4px; }
.sort.on .caret { visibility: visible; }
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; } td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:hover td { background: var(--bg-hover); } tr:hover td { background: var(--bg-hover); }
.name { max-width: 360px; } .name { max-width: 360px; }
@@ -167,7 +212,8 @@
.good { color: var(--speaking); } .good { color: var(--speaking); }
.bad { color: var(--warn); } .bad { color: var(--warn); }
.act { width: 1%; } .act { width: 1%; }
.act .btn { padding: 4px 12px; font-size: 13px; } .acts { display: flex; gap: 4px; }
.go, .go:hover { color: var(--speaking); }
.dim { color: var(--text-dim); } .dim { color: var(--text-dim); }
.small { font-size: 12px; margin-top: 10px; } .small { font-size: 12px; margin-top: 10px; }
.error { color: var(--danger); } .error { color: var(--danger); }
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Modal from './Modal.svelte';
import { session } from '../lib/session.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
const info = $derived(session.serverInfo);
const server = $derived(session.server);
const cert = $derived(session.serverCert);
const voice = $derived(session.voiceTransport);
</script>
<Modal title="Server information" {onclose} width={520}>
{#if info && server}
<h3>Server</h3>
<dl>
<dt>Address</dt><dd class="mono">{server.host}:{server.port}</dd>
<dt>Version</dt><dd>{info.version || 'unknown'}</dd>
<dt>People</dt><dd>{info.users}{info.config.maxUsers ? ` of ${info.config.maxUsers}` : ''}</dd>
<dt>Channels</dt><dd>{info.channels}</dd>
</dl>
<h3>Connection</h3>
<dl>
<dt>Ping (TCP)</dt><dd>{session.rtt ? `${session.rtt} ms` : 'n/a'}</dd>
<dt>Voice</dt><dd>{voice.udp ? `Encrypted UDP${voice.rtt ? `, ${Math.round(voice.rtt)} ms` : ''}` : 'Through the TCP connection'}</dd>
<dt>Certificate</dt><dd>{cert ? (cert.authorized ? 'Signed by a trusted authority' : 'Self-signed or untrusted, pinned on first connect') : 'unknown'}</dd>
{#if cert}<dt>SHA-256</dt><dd class="mono">{cert.fingerprint}</dd>{/if}
</dl>
<h3>Limits</h3>
<dl>
<dt>Voice bitrate</dt><dd>{info.maxBandwidth ? `up to ${Math.round(info.maxBandwidth / 1000)} kbit/s` : 'unknown'}</dd>
<dt>Message length</dt><dd>{info.config.messageLength ? `${info.config.messageLength} characters` : 'unlimited'}</dd>
<dt>Image messages</dt><dd>{info.config.imageMessageLength ? `${info.config.imageMessageLength} characters` : 'unlimited'}</dd>
<dt>HTML in messages</dt><dd>{info.config.allowHtml ? 'allowed' : 'not allowed'}</dd>
</dl>
{/if}
{#snippet footer()}
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
h3 { margin: 18px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
h3:first-child { margin-top: 8px; }
dl { display: grid; grid-template-columns: 130px 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
dt { color: var(--text-faint); }
dd { margin: 0; overflow-wrap: anywhere; user-select: text; }
.mono { font: 12px var(--mono); }
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
</style>
+15 -9
View File
@@ -7,6 +7,7 @@
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts'; import { native } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts'; import { fetchUploadInfo } from '../lib/upload.ts';
import { previewMode } from '../lib/preview.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props(); let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
@@ -15,6 +16,9 @@
const s = store.settings; const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n')); let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state(''); let testResult = $state('');
// Settings that only work through the upload host are disabled until it is set up
const hasUpload = $derived(!!(s.uploadHost.trim() && s.uploadKey.trim()));
const needsUpload = $derived(hasUpload ? undefined : 'Needs an upload host and key');
// Changes apply right away // Changes apply right away
function saveFiles() { function saveFiles() {
@@ -65,36 +69,36 @@
<label for="s-key">Upload key</label> <label for="s-key">Upload key</label>
<input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" /> <input id="s-key" type="password" bind:value={s.uploadKey} onchange={saveFiles} placeholder="cu_..." autocomplete="off" />
<div class="row"> <div class="row">
<div> <div title={needsUpload}>
<label for="s-exp">Files expire after</label> <label for="s-exp" class:off={!hasUpload}>Files expire after</label>
<select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles}> <select id="s-exp" bind:value={s.uploadExpiry} onchange={saveFiles} disabled={!hasUpload}>
<option value="1h">1 hour</option> <option value="1h">1 hour</option>
<option value="24h">24 hours</option> <option value="24h">24 hours</option>
<option value="7d">7 days</option> <option value="7d">7 days</option>
<option value="30d">30 days</option> <option value="30d">30 days</option>
</select> </select>
</div> </div>
<button class="btn" disabled={!s.uploadHost || !s.uploadKey} onclick={test}>Test key</button> <button class="btn" disabled={!hasUpload} title={needsUpload} onclick={test}>Test key</button>
</div> </div>
{#if testResult}<p class="help">{testResult}</p>{/if} {#if testResult}<p class="help">{testResult}</p>{/if}
<label class="check"><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} /> Send image previews for regular Mumble clients</label> <label class="check" class:off={!hasUpload} title={needsUpload}><input type="checkbox" bind:checked={s.sendThumbnails} onchange={saveFiles} disabled={!hasUpload} /> Send image previews for regular Mumble clients</label>
<h3>Embeds</h3> <h3>Embeds</h3>
<label for="s-embed">Also show media inline from these hosts</label> <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> <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> <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> <label for="s-lp">Link previews</label>
<select id="s-lp" bind:value={s.linkPreviews} onchange={saveFiles}> <!-- Shows the mode in effect; a stored "upload host" choice comes back once the key is set -->
<option value="host">Fetched by the upload host (keeps your IP private)</option> <select id="s-lp" value={previewMode()} onchange={e => { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}>
<option value="host" disabled={!hasUpload}>Fetched by the upload host (keeps your IP private){hasUpload ? '' : ' - needs an upload host and key'}</option>
<option value="direct">Fetched by this computer (linked sites see your IP)</option> <option value="direct">Fetched by this computer (linked sites see your IP)</option>
<option value="off">Off</option> <option value="off">Off</option>
</select> </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> <label for="s-yt">YouTube links</label>
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}> <select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
<option value="off">Plain links</option>
<option value="click">Player card, loads from YouTube when clicked</option> <option value="click">Player card, loads from YouTube when clicked</option>
<option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option> <option value="thumbnail">Player card with thumbnail (loads the thumbnail from YouTube)</option>
<option value="off">Plain links</option>
</select> </select>
{:else} {:else}
<p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p> <p class="help">Servers recognize you by your certificate. The default is used unless a server is set to another one.</p>
@@ -116,5 +120,7 @@
.row > div { flex: 1; } .row > div { flex: 1; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; } .check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 14px; }
.check input { width: auto; } .check input { width: auto; }
.off { color: var(--text-faint); }
select:disabled, input:disabled { opacity: 0.5; }
textarea { resize: vertical; } textarea { resize: vertical; }
</style> </style>
+31 -14
View File
@@ -22,6 +22,9 @@
const roots = $derived(session.childrenOf(null)); const roots = $derived(session.childrenOf(null));
const self = $derived(session.self); const self = $derived(session.self);
// Without a connection the buttons show the remembered choice, applied at the next connect
const muted = $derived(self ? self.selfMute : sessions.mute);
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
const cert = $derived(session.serverCert); const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized); const trusted = $derived(!!cert?.authorized);
@@ -111,8 +114,8 @@
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button> <button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
</div> </div>
{/if} {/if}
{#if self} <footer class="me">
<footer class="me"> {#if self}
<button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }} <button class="profile" title="Your profile" onclick={() => { session.showUser(self.session); ui.panelOpen = true; }}
oncontextmenu={e => menu.show(e, userMenu(self))}> oncontextmenu={e => menu.show(e, userMenu(self))}>
<Avatar name={self.name} size={32} speaking={voice.transmitting} /> <Avatar name={self.name} size={32} speaking={voice.transmitting} />
@@ -121,18 +124,27 @@
<span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span> <span>{voice.error ? 'Voice unavailable' : voice.settings.mode === 'ptt' && !self.selfMute ? (voice.pttDown ? 'Talking' : 'Push to talk') : session.channel(self.channelId)?.name || 'Root'}</span>
</span> </span>
</button> </button>
<button class="icon-btn" class:on={self.selfMute} title={self.selfMute ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'} {:else}
aria-label={self.selfMute ? 'Unmute' : 'Mute'} <span class="who offline-who" title="Mute and deafen apply when you connect">
onclick={() => session.setSelfMute(!self.selfMute)} oncontextmenu={e => openQuick(e, 'input')}> <strong>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</strong>
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} /> <span>{deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'}</span>
</button> </span>
<button class="icon-btn" class:on={self.selfDeaf} title={self.selfDeaf ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'} {/if}
aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'} <button class="icon-btn info" title="Server information" aria-label="Server information"
onclick={() => session.setSelfDeaf(!self.selfDeaf)} oncontextmenu={e => openQuick(e, 'output')}> disabled={session.status !== 'connected'} onclick={() => (ui.serverInfo = true)}>
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} /> <Icon name="info" size={18} />
</button> </button>
</footer> <button class="icon-btn" class:on={muted} title={muted ? 'Unmute (right-click for microphone options)' : 'Mute (right-click for microphone options)'}
{/if} aria-label={muted ? 'Unmute' : 'Mute'}
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => openQuick(e, 'input')}>
<Icon name={muted ? 'mic-off' : 'mic'} size={18} />
</button>
<button class="icon-btn" class:on={deafened} title={deafened ? 'Undeafen (right-click for output options)' : 'Deafen (right-click for output options)'}
aria-label={deafened ? 'Undeafen' : 'Deafen'}
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => openQuick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={18} />
</button>
</footer>
</aside> </aside>
<style> <style>
@@ -163,9 +175,14 @@
.elsewhere span { flex: 1; min-width: 0; } .elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; } .elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); } .me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); }
/* Stacked layout: the bar leads the stack, like the desktop client's toolbar */
.sidebar.fill .me { order: -1; border-bottom: 1px solid var(--line); }
.sidebar.fill .me .profile, .sidebar.fill .me .offline-who { display: none; }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; } .profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); } .profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; } .who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
.who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .who strong, .who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who span { font-size: 12px; color: var(--text-dim); } .who span { font-size: 12px; color: var(--text-dim); }
.info, .info:hover { color: var(--warn); }
.offline-who { padding: 2px 2px 2px 6px; color: var(--text-dim); }
</style> </style>
+48 -4
View File
@@ -92,6 +92,18 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: welcome page without composer, manage identities'); console.log('ok: welcome page without composer, manage identities');
// Mute and deafen are there without a connection
const me = page.locator('.me');
await me.getByText('Not connected').waitFor();
assert.ok(await me.getByRole('button', { name: 'Server information' }).isDisabled());
await me.getByRole('button', { name: 'Deafen', exact: true }).click();
await me.getByRole('button', { name: 'Unmute', exact: true }).waitFor();
await me.getByText('Deafened').waitFor();
await me.getByRole('button', { name: 'Unmute', exact: true }).click();
await me.getByRole('button', { name: 'Deafen', exact: true }).waitFor();
await me.getByRole('button', { name: 'Mute', exact: true }).waitFor();
console.log('ok: mute and deafen shown while disconnected');
// Settings: upload host + key // Settings: upload host + key
if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
@@ -256,15 +268,23 @@ try {
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click(); await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
console.log('ok: formatted message from the toolbar'); console.log('ok: formatted message from the toolbar');
// YouTube links become a click-to-play card; nothing loads from YouTube before the click // YouTube links are plain links by default
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>'); bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
const card = page.locator('.msg', { hasText: 'watch' }).locator('.yt'); const ytMsg = page.locator('.msg', { hasText: 'watch' });
await ytMsg.getByRole('link', { name: 'https://youtu.be/dQw4w9WgXcQ?t=1m30s' }).waitFor();
assert.equal(await ytMsg.locator('.yt').count(), 0, 'no YouTube card by default');
// Turned on, they become a click-to-play card; nothing loads from YouTube before the click
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('YouTube links').selectOption('click');
await page.getByRole('button', { name: 'Done', exact: true }).click();
const card = ytMsg.locator('.yt');
await card.getByText('from 1:30, click to play').waitFor(); await card.getByText('from 1:30, click to play').waitFor();
assert.equal(await card.locator('iframe').count(), 0); assert.equal(await card.locator('iframe').count(), 0);
assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request by default'); assert.equal(await card.locator('.facade').evaluate(el => getComputedStyle(el).backgroundImage), 'none', 'no thumbnail request in click mode');
await card.locator('.facade').click(); await card.locator('.facade').click();
assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90'); assert.equal(await card.locator('iframe').getAttribute('src'), 'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?autoplay=1&rel=0&start=90');
console.log('ok: YouTube link embedded as click-to-play'); console.log('ok: YouTube link plain by default, click-to-play when turned on');
// Bob -> DM // Bob -> DM
const alice = [...bob.users.values()].find(u => u.name === 'alice')!; const alice = [...bob.users.values()].find(u => u.name === 'alice')!;
@@ -359,6 +379,15 @@ try {
const row = pub.locator('tr', { hasText: 'E2E Murmur' }); const row = pub.locator('tr', { hasText: 'E2E Murmur' });
await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 }); await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 });
await row.getByText(/ms$/).waitFor(); await row.getByText(/ms$/).waitFor();
await row.getByRole('button', { name: 'Connect' }).waitFor();
// Column headers sort; a second click reverses
const firstRow = pub.locator('tbody tr').first();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await pub.getByRole('button', { name: 'Server', exact: true }).click();
await firstRow.getByText('Offline one').waitFor();
await pub.getByRole('button', { name: 'People', exact: true }).click();
await firstRow.getByText('E2E Murmur').waitFor();
await row.getByRole('button', { name: 'Add' }).click(); await row.getByRole('button', { name: 'Add' }).click();
assert.equal(await page.getByLabel('Address').inputValue(), host); assert.equal(await page.getByLabel('Address').inputValue(), host);
assert.equal(await page.getByLabel('Port').inputValue(), port); assert.equal(await page.getByLabel('Port').inputValue(), port);
@@ -532,6 +561,16 @@ try {
await page.locator('.panel h3', { hasText: 'bob' }).waitFor(); await page.locator('.panel h3', { hasText: 'bob' }).waitFor();
console.log('ok: user context menu and profile'); console.log('ok: user context menu and profile');
// Server information from the button next to mute
await page.locator('.me').getByRole('button', { name: 'Server information' }).click();
const srvInfo = page.getByRole('dialog', { name: 'Server information' });
await srvInfo.getByText(`${host}:${port}`).waitFor();
await srvInfo.getByText(/^\d+ of \d+$/).waitFor();
await srvInfo.getByText(/^\d+ characters$/).first().waitFor();
await srvInfo.getByRole('button', { name: 'Close', exact: true }).last().click();
await srvInfo.waitFor({ state: 'detached' });
console.log('ok: server information dialog');
// Information: own connection shows client details and the full certificate // Information: own connection shows client details and the full certificate
await page.locator('.sidebar .user.self').click({ button: 'right' }); await page.locator('.sidebar .user.self').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Information' }).click(); await page.getByRole('menuitem', { name: 'Information' }).click();
@@ -729,6 +768,11 @@ try {
await page.getByTitle('Settings').click(); await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Chat and files' }).click(); await page.getByRole('tab', { name: 'Chat and files' }).click();
await page.getByLabel('Upload host').fill(''); await page.getByLabel('Upload host').fill('');
// Settings that need the upload host are disabled and previews show as off
assert.ok(await page.getByLabel('Files expire after').isDisabled());
assert.ok(await page.getByLabel('Send image previews for regular Mumble clients').isDisabled());
assert.equal(await page.getByLabel('Link previews').inputValue(), 'off');
assert.ok(await page.getByLabel('Link previews').locator('option[value=host]').isDisabled());
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
const big = path.join(userData, 'big.png'); const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200)); writeFileSync(big, testPng(1600, 1200));