diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0640877 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index aeabde0..df26759 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index ebe6049..24a0e48 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 d9f612a..952c9c7 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 5789236..c0c7092 100644 Binary files a/docs/screenshots/profile.png and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/voice-settings.png b/docs/screenshots/voice-settings.png index 06e470e..038687c 100644 Binary files a/docs/screenshots/voice-settings.png and b/docs/screenshots/voice-settings.png differ diff --git a/electron/link-preview.ts b/electron/link-preview.ts new file mode 100644 index 0000000..e133f93 --- /dev/null +++ b/electron/link-preview.ts @@ -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(); + +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 { + 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 { + 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('')) 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(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>') + .replace(/ /g, ' ').replace(/&/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 { + const head = html.split(/<\/head>/i)[0]; + const meta: Record = {}; + for (const [, attrs] of head.matchAll(/]+?)\/?>/gi)) { + const a: Record = {}; + 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 = /]*>([\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 { + 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; +} diff --git a/electron/main.ts b/electron/main.ts index bd83022..aa589c1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() })); diff --git a/electron/preload.ts b/electron/preload.ts index ad21dc4..61a6c0a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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: { diff --git a/src/App.svelte b/src/App.svelte index 2d8bd42..dbfdde3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -60,7 +60,7 @@ {#if layout === 'narrow'}{/if} {/if} - (navOpen = true)} onpanel={togglePanel} /> + (navOpen = true)} onpanel={togglePanel} /> {#if showPanel} {#if layout === 'wide'} diff --git a/src/core/client.ts b/src/core/client.ts index a8546eb..68fb999 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -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(['TextMessage', 'ChannelState', 'ACL', 'Version']); export interface Channel { @@ -142,8 +144,8 @@ export class MumbleClient extends Emitter { private reader = new FrameReader(); private pingTimer: ReturnType | 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 | null = null; @@ -206,14 +208,10 @@ export class MumbleClient extends Emitter { 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 { 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)); } } diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 75c2934..7bcdb26 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -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; } diff --git a/src/lib/html.ts b/src/lib/html.ts index 1945a35..6c56467 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -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(/^()+|()+$/g, ''), embeds }; diff --git a/src/lib/menu.svelte.ts b/src/lib/menu.svelte.ts index 6aefdd9..e5754af 100644 --- a/src/lib/menu.svelte.ts +++ b/src/lib/menu.svelte.ts @@ -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 diff --git a/src/lib/native.ts b/src/lib/native.ts index 4673f4f..fbb6407 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -5,6 +5,7 @@ interface NativeApi { describeCerts(ders: Uint8Array[]): Promise; 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; identities: { list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; create(name: string, email: string): Promise; @@ -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; diff --git a/src/lib/preview.ts b/src/lib/preview.ts new file mode 100644 index 0000000..7e991a1 --- /dev/null +++ b/src/lib/preview.ts @@ -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>(); + +export function linkPreview(url: string): Promise { + 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; +} diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index b92b7ac..3c967b8 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -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 { diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index 55b4941..0f74faa 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -48,7 +48,7 @@ {/if} + {#if showStage && session.status === 'connected'}{/if} + {#snippet empty()}
diff --git a/src/ui/ContextMenu.svelte b/src/ui/ContextMenu.svelte index f4870bc..97fb4f2 100644 --- a/src/ui/ContextMenu.svelte +++ b/src/ui/ContextMenu.svelte @@ -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>({}); + $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('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} + {:else if item.slider} + {@const sl = item.slider} +
+ {item.label}{sl.format(sliderValues[i] ?? sl.value)} + { const v = Number(e.currentTarget.value); sliderValues[i] = v; sl.oninput(v); }} + ondblclick={() => { sliderValues[i] = 1; sl.oninput(1); }} /> +
{:else} + + + + {#if !collapsed} +
    + {#each people as u (u.session)} +
  • + +
  • + {/each} +
+ {/if} + +{/if} + + diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 86235d1..cda349e 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -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] }, 'https://github.com/mumble-voip/mumble'); + 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] }, 'https://www.mumble.info/'); + 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(res => bob.on('text', m => { if (m.html.startsWith('$/); 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') }); diff --git a/test/e2e/screenshots.ts b/test/e2e/screenshots.ts index 35dc6f5..a501711 100644 --- a/test/e2e/screenshots.ts +++ b/test/e2e/screenshots.ts @@ -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: https://github.com/mumble-voip/mumble'); +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] }, 'no. not again (ok it is a good song)'); 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(); diff --git a/test/server.test.ts b/test/server.test.ts index 098af4b..e1c56f2 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -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(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(); + } +}); +