diff --git a/README.md b/README.md index a4f13e7..997af12 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ mumh5 keeps the foundation and replaces the experience. - Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers - People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. Viewers set the stream's volume - No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams -- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you enter one in Settings, Voice (the proxy's address works there too), and none is contacted unless you do +- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), and none is contacted unless you do - Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested ### Chat diff --git a/src/App.svelte b/src/App.svelte index b12d3da..e5ef036 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -15,6 +15,7 @@ import ChannelDialog from './ui/ChannelDialog.svelte'; import PublicServers from './ui/PublicServers.svelte'; import ServerInfoDialog from './ui/ServerInfoDialog.svelte'; + import ImageViewer from './ui/ImageViewer.svelte'; import ShareDialog from './ui/ShareDialog.svelte'; import { share } from './lib/share.svelte.ts'; import ResizeHandle from './ui/ResizeHandle.svelte'; @@ -63,13 +64,24 @@ else ui.panelOpen = false; } + // Images in rendered text (messages, descriptions, the quick preview) and image embeds open + // large on click. Not in editors, link preview cards or the YouTube card. + function imageClick(e: MouseEvent) { + if (e.button !== 0 || !(e.target instanceof HTMLImageElement)) return; + const img = e.target; + if (!img.closest('.html, .embed') || img.closest('[contenteditable], .rich, .card, .yt')) return; + e.preventDefault(); + e.stopPropagation(); + ui.image = img.currentSrc || img.src; + } + const closeOverlays = () => { navOpen = false; if (!panelInline) ui.panelOpen = false; }; - + {#snippet rail()} (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} @@ -134,6 +146,7 @@ {/if} {#if share.dialog} (share.dialog = null)} />{/if} {#if ui.prompt} { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if} + diff --git a/src/ui/ServerRail.svelte b/src/ui/ServerRail.svelte index 6e541d2..bca505a 100644 --- a/src/ui/ServerRail.svelte +++ b/src/ui/ServerRail.svelte @@ -96,6 +96,8 @@ diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 3c02482..8872d76 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -334,6 +334,21 @@ try { await page.getByRole('button', { name: 'Done', exact: true }).click(); console.log('ok: quick audio options on right-click'); + // STUN server for screen sharing: none by default, presets, or a typed address + await page.getByTitle('Settings').click(); + const stun = page.getByLabel('STUN server', { exact: true }); + assert.equal(await stun.inputValue(), ''); + await stun.selectOption({ label: 'Cloudflare (stun.cloudflare.com:3478)' }); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.cloudflare.com:3478'); + await stun.selectOption('custom'); + await page.getByLabel('STUN server address').fill('stun.example.org:3478'); + await page.getByLabel('STUN server address').blur(); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), 'stun.example.org:3478'); + await stun.selectOption(''); + assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('mumh5.settings')!).stunServer), ''); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + console.log('ok: STUN server choices'); + // Color schemes apply right away and are remembered await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Appearance' }).click(); @@ -716,7 +731,15 @@ try { await page.locator('.channel .name', { hasText: 'Lobby' }).click({ button: 'right' }); await page.getByRole('menuitem', { name: 'Join channel' }).click(); await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor(); - console.log('ok: channel context menu joins'); + // Messaging a channel from its menu opens the side chat, ready to type, without joining + await page.locator('.channel .name', { hasText: 'Games' }).click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Send message to channel' }).click(); + const sideInput = page.locator('.panel .composer [contenteditable]'); + await sideInput.waitFor(); + assert.equal(await sideInput.getAttribute('data-placeholder'), 'Message #Games'); + await page.waitForFunction(() => document.activeElement === document.querySelector('.panel .composer [contenteditable]'), null, { timeout: 3000 }); + await page.locator('.me .who span', { hasText: 'Lobby' }).waitFor(); + console.log('ok: channel context menu joins, and messages a channel from the side chat'); // Side panel can be resized by dragging its left edge const panelBox = (await page.locator('aside.panel').boundingBox())!; @@ -856,6 +879,19 @@ try { assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`); console.log(`ok: inline image without upload host (${inline.length} chars)`); + // A click on an image in the chat shows it large; Escape or a click closes it + await page.locator('.chat .msg .html img').last().click(); + const viewer = page.getByRole('dialog', { name: 'Image' }); + await viewer.waitFor(); + const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]); + assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`); + await page.keyboard.press('Escape'); + await viewer.waitFor({ state: 'detached' }); + await page.locator('.chat .msg .html img').last().click(); + await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } }); + await viewer.waitFor({ state: 'detached' }); + console.log('ok: images open large on click'); + // ─── Several servers at once ───────────────────────────────────────────── const userState = (name: string) => [...bob.users.values()].find(u => u.name === name); const until2 = (label: string, cond: () => boolean) => within(new Promise(res => { @@ -881,6 +917,10 @@ try { await page.locator('.channel .name', { hasText: 'Root' }).dblclick(); await until2('voice moved to the second server', () => !userState('alice-b')?.selfDeaf && !!userState('alice')?.selfDeaf); assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Second')), true); + // The server with voice leads the rail, although it was added last + const railTop = (title: string) => page.locator(`.rail .tile[title^="${title}"]`).boundingBox().then(b => b!.y); + assert.ok(await railTop('Second') < await railTop('Test Server'), 'voice server on top'); + assert.match(await page.locator('.rail .tile').first().getAttribute('title') ?? '', /^Test Server/, 'the saved order is untouched'); console.log('ok: voice follows the channel you join'); // Messages on the server not on screen show as unread on its tile diff --git a/test/e2e/share.e2e.ts b/test/e2e/share.e2e.ts index f33c134..05a3043 100644 --- a/test/e2e/share.e2e.ts +++ b/test/e2e/share.e2e.ts @@ -140,18 +140,29 @@ try { await tile.waitFor(); console.log('ok: a late joiner sees who is sharing'); - // The tiles fill the stage, and the stage can be made taller or shorter - const tileWidth = async () => Math.round((await tile.boundingBox())!.width); - const before = await tileWidth(); + // The tiles fill the stage exactly, whatever its size + const fills = () => bob.page.evaluate(() => { + const grid = document.querySelector('.stage .tiles')!.getBoundingClientRect(); + const tiles = [...document.querySelectorAll('.stage .tile')].map(e => e.getBoundingClientRect()); + const left = Math.min(...tiles.map(r => r.left)), right = Math.max(...tiles.map(r => r.right)); + const top = Math.min(...tiles.map(r => r.top)), bottom = Math.max(...tiles.map(r => r.bottom)); + return { ok: left - grid.left < 10 && grid.right - right < 10 && top - grid.top < 10 && grid.bottom - bottom < 10, height: Math.round(tiles[0].height), count: tiles.length }; + }); + const first = await fills(); + assert.ok(first.ok && first.count === 2, `tiles fill the stage: ${JSON.stringify(first)}`); const handle = bob.page.getByRole('separator', { name: 'Resize the tiles' }); await handle.focus(); for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowDown'); - await bob.page.waitForFunction(w => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().width) > w, before); - const taller = await tileWidth(); + await bob.page.waitForFunction(h => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().height) > h, first.height); + const taller = await fills(); + assert.ok(taller.ok, 'still filled after making the stage taller'); + await bob.page.setViewportSize({ width: 820, height: 700 }); + await bob.page.waitForFunction(() => document.querySelector('.stage .tiles')!.getBoundingClientRect().width < 700); + assert.ok((await fills()).ok, 'still filled after making the window narrower'); + await bob.page.setViewportSize({ width: 1280, height: 800 }); for (let i = 0; i < 8; i++) await bob.page.keyboard.press('ArrowUp'); - await bob.page.waitForFunction(w => Math.round(document.querySelector('.stage .tile')!.getBoundingClientRect().width) < w, taller); if (process.env.SHOTS_DIR) await bob.page.screenshot({ path: `${process.env.SHOTS_DIR}/tiles.png` }); - console.log(`ok: tiles scale with the stage height (${before} to ${taller} px wide)`); + console.log(`ok: tiles fill the stage at every size (${first.height} to ${taller.height} px tall)`); await tile.click(); await bob.page.getByRole('button', { name: 'Continue' }).click(); diff --git a/test/e2e/web.e2e.ts b/test/e2e/web.e2e.ts index 8dddc65..91a1acb 100644 --- a/test/e2e/web.e2e.ts +++ b/test/e2e/web.e2e.ts @@ -188,6 +188,13 @@ try { await page2.getByRole('button', { name: 'Continue' }).click(); await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 }); await page.locator('.stage .status', { hasText: '1 watching' }).waitFor(); + // Phone-sized window: the picture still has room and frames + await page2.setViewportSize({ width: 390, height: 780 }); + await page2.waitForFunction(() => { + const v = document.querySelector('.spot video') as HTMLVideoElement | null; + return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300; + }, null, { timeout: 15000 }); + if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` }); console.log('ok: screen sharing between two browsers'); console.log('WEB E2E PASSED');