diff --git a/README.md b/README.md index c3c7ae5..e1be241 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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. ### 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 | | 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 | +| 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 | | 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 | @@ -80,7 +80,7 @@ mumh5 keeps the foundation and replaces the experience. - Messages grouped by author, direct messages, unread badges - 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 +- 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 - Incoming HTML is sanitized; remote images from unknown hosts are never loaded - 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. - **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. --- diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index 876c251..638dc8c 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png index ef6095d..05d09c8 100644 Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png index 3f49f6a..e4fac83 100644 Binary files a/docs/screenshots/profile.png and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/settings-appearance.png b/docs/screenshots/settings-appearance.png index 071e553..3daf4c0 100644 Binary files a/docs/screenshots/settings-appearance.png and b/docs/screenshots/settings-appearance.png differ diff --git a/docs/screenshots/theme-frost.png b/docs/screenshots/theme-frost.png index ebde921..93dbdd5 100644 Binary files a/docs/screenshots/theme-frost.png and b/docs/screenshots/theme-frost.png differ diff --git a/docs/screenshots/theme-light.png b/docs/screenshots/theme-light.png index 5e75e90..061da93 100644 Binary files a/docs/screenshots/theme-light.png and b/docs/screenshots/theme-light.png differ diff --git a/docs/screenshots/theme-midnight.png b/docs/screenshots/theme-midnight.png index 13f76dd..ea5b83a 100644 Binary files a/docs/screenshots/theme-midnight.png and b/docs/screenshots/theme-midnight.png differ diff --git a/docs/screenshots/theme-win95.png b/docs/screenshots/theme-win95.png index 6b263d6..12fe3db 100644 Binary files a/docs/screenshots/theme-win95.png and b/docs/screenshots/theme-win95.png differ diff --git a/docs/screenshots/voice-settings.png b/docs/screenshots/voice-settings.png index 8b2f2d9..ce58e01 100644 Binary files a/docs/screenshots/voice-settings.png and b/docs/screenshots/voice-settings.png differ diff --git a/src/App.svelte b/src/App.svelte index ac110f2..82cc44f 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,6 +14,7 @@ import UserInfoDialog from './ui/UserInfoDialog.svelte'; import ChannelDialog from './ui/ChannelDialog.svelte'; import PublicServers from './ui/PublicServers.svelte'; + import ServerInfoDialog from './ui/ServerInfoDialog.svelte'; import ResizeHandle from './ui/ResizeHandle.svelte'; import { identities } from './lib/identities.svelte.ts'; import { session } from './lib/session.svelte.ts'; @@ -115,6 +116,7 @@ {#if editing} (editing = null)} />{/if} {#if ui.addServer} (ui.addServer = null)} />{/if} {#if ui.publicServers} (ui.publicServers = false)} />{/if} +{#if ui.serverInfo && session.status === 'connected'} (ui.serverInfo = false)} />{/if} {#if ui.settingsOpen} { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.quickAudio}{/if} {#if ui.userInfo != null && session.status === 'connected'} diff --git a/src/lib/icons.ts b/src/lib/icons.ts index b5f3fd1..06c56b5 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -6,6 +6,8 @@ export const ICON_PATHS: Record = { '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', 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', hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', channel: 'M11 5L6 9H2v6h4l5 4zM15.5 8.5a5 5 0 0 1 0 7', diff --git a/src/lib/preview.ts b/src/lib/preview.ts index 7e991a1..086cd15 100644 --- a/src/lib/preview.ts +++ b/src/lib/preview.ts @@ -4,9 +4,16 @@ import { store } from './settings.svelte.ts'; // One request per URL and mode per app run const cache = new Map>(); -export function linkPreview(url: string): Promise { +// 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 mode = linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews; + return linkPreviews === 'host' && !(uploadHost && uploadKey) ? 'off' : linkPreviews; +} + +export function linkPreview(url: string): Promise { + const { uploadHost, uploadKey } = store.settings; + const mode = previewMode(); if (mode === 'off') return Promise.resolve(null); const id = `${mode}|${url}`; let p = cache.get(id); diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 217248e..066b4e5 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -161,6 +161,14 @@ export class Session { 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 get voiceTransport(): { udp: boolean; rtt: number } { void this.tick; @@ -663,8 +671,17 @@ export class Session { // ─── Voice state and moderation ───────────────────────────────────────────── - setSelfMute(v: boolean) { this.client?.setSelfMute(v); } - setSelfDeaf(v: boolean) { this.client?.setSelfDeaf(v); } + // The choice is remembered, so it also works while disconnected and carries to the next connect + 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); } setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); } @@ -683,6 +700,10 @@ class Sessions { activeId = $state(null); voiceId = $state(null); 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 private saved = new Map(); @@ -725,8 +746,15 @@ class Sessions { } onSynced(s: Session): void { - if (!this.voiceId || !this.voiceSession?.client) this.setVoice(s, true); - else this.toBackground(s); + const want = { mute: this.mute, deaf: this.deaf }; + 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 { diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 965137b..a84c09c 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -46,7 +46,7 @@ const defaults: Settings = { embedHosts: [], sendThumbnails: true, compact: false, - youtube: 'click', + youtube: 'off', linkPreviews: 'host', theme: 'dark', publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0', diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index c58214a..d74da50 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -27,6 +27,7 @@ class Ui { quickAudio = $state<{ kind: 'input' | 'output'; x: number; y: number } | null>(null); settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice'); publicServers = $state(false); + serverInfo = $state(false); // Add-server dialog, optionally prefilled (from the public list) addServer = $state<{ host?: string; port?: number; label?: string } | null>(null); // Channel dialog: create a subchannel of `parent`, or edit channel `id` diff --git a/src/themes.css b/src/themes.css index 7faf7ba..2e31f9a 100644 --- a/src/themes.css +++ b/src/themes.css @@ -164,6 +164,8 @@ box-shadow: var(--sunken); } :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"] input[type=range] { accent-color: #000080; } diff --git a/src/ui/Composer.svelte b/src/ui/Composer.svelte index 4905335..c6ac013 100644 --- a/src/ui/Composer.svelte +++ b/src/ui/Composer.svelte @@ -132,11 +132,11 @@ diff --git a/src/ui/SettingsDialog.svelte b/src/ui/SettingsDialog.svelte index 922131b..22b8111 100644 --- a/src/ui/SettingsDialog.svelte +++ b/src/ui/SettingsDialog.svelte @@ -7,6 +7,7 @@ import { store } from '../lib/settings.svelte.ts'; import { native } from '../lib/native.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(); @@ -15,6 +16,9 @@ const s = store.settings; let embedHosts = $state(s.embedHosts.join('\n')); 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 function saveFiles() { @@ -65,36 +69,36 @@
-
- -
- +
{#if testResult}

{testResult}

{/if} - +

Embeds

Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.

- { s.linkPreviews = e.currentTarget.value as typeof s.linkPreviews; saveFiles(); }}> + - {#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}

Needs an upload host and key above; until then no previews are shown.

{/if} {:else}

Servers recognize you by your certificate. The default is used unless a server is set to another one.

@@ -116,5 +120,7 @@ .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 input { width: auto; } + .off { color: var(--text-faint); } + select:disabled, input:disabled { opacity: 0.5; } textarea { resize: vertical; } diff --git a/src/ui/Sidebar.svelte b/src/ui/Sidebar.svelte index 8b98324..79541a4 100644 --- a/src/ui/Sidebar.svelte +++ b/src/ui/Sidebar.svelte @@ -22,6 +22,9 @@ const roots = $derived(session.childrenOf(null)); 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 trusted = $derived(!!cert?.authorized); @@ -111,8 +114,8 @@ {/if} - {#if self} -
+
+ {#if self} - - -
- {/if} + {:else} + + {session.status === 'connecting' ? 'Connecting...' : 'Not connected'} + {deafened ? 'Deafened' : muted ? 'Muted' : 'Microphone on'} + + {/if} + + + +
diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 425bb01..fa77595 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -92,6 +92,18 @@ try { await page.getByRole('button', { name: 'Done', exact: true }).click(); 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 if (process.env.UPLOAD_HOST && process.env.UPLOAD_KEY) { await page.getByTitle('Settings').click(); @@ -256,15 +268,23 @@ try { await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click(); 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 https://youtu.be/dQw4w9WgXcQ?t=1m30s'); - 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(); 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(); 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 const alice = [...bob.users.values()].find(u => u.name === 'alice')!; @@ -359,6 +379,15 @@ try { const row = pub.locator('tr', { hasText: 'E2E Murmur' }); await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 }); 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(); assert.equal(await page.getByLabel('Address').inputValue(), host); assert.equal(await page.getByLabel('Port').inputValue(), port); @@ -532,6 +561,16 @@ try { await page.locator('.panel h3', { hasText: 'bob' }).waitFor(); 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 await page.locator('.sidebar .user.self').click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Information' }).click(); @@ -729,6 +768,11 @@ try { await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Chat and files' }).click(); 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(); const big = path.join(userData, 'big.png'); writeFileSync(big, testPng(1600, 1200));