Add channel and permission management, sounds, themes and the public server list #2

Merged
kibi merged 1 commits from link-previews-voice-tiles into main 2026-09-30 23:05:00 +00:00
53 changed files with 2684 additions and 170 deletions
+10
View File
@@ -52,6 +52,12 @@ Voice runs on one server at a time (where you last joined a channel); background
- New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests. - New features get an E2E step in `test/e2e/app.e2e.ts`; protocol logic gets unit or server tests.
- Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`). - Privacy rule: opening a chat must never contact hosts the user did not choose. Remote media only from the upload host and user-listed hosts; YouTube is click-to-play (`youtube-nocookie.com`).
## Browser and CSP pitfalls (all hit before)
- The CSP allows media only from `self`, `blob:` and http(s). `data:` audio is blocked silently: play user files through object URLs. Large user files go to IndexedDB (`src/lib/blobstore.ts`), not localStorage.
- `window.prompt` does not exist in Electron; use `ui.prompt`.
- A drop handler must read derived state before clearing the drag item it derives from.
## Svelte reactivity pitfalls (all hit before) ## Svelte reactivity pitfalls (all hit before)
- `$state` wraps assigned objects in proxies: `this.server !== server` compares proxy to raw object and is always true. Use ids or counters. - `$state` wraps assigned objects in proxies: `this.server !== server` compares proxy to raw object and is always true. Use ids or counters.
@@ -65,6 +71,10 @@ Voice runs on one server at a time (where you last joined a channel); background
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames. - 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob. - Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
- Renaming while connected is not possible; mumh5 reconnects with the new name. - Renaming while connected is not possible; mumh5 reconnects with the new name.
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
- Murmur caches permissions per channel object; deleting and recreating channels quickly can leave stale denials. E2E runs use a fresh server.
- The public list (publist.mumble.info) refuses non-browser TLS clients with HTTP 501; fetch it with Electron's `net.fetch`, not Node https.
## Running the app in this environment ## Running the app in this environment
+18 -1
View File
@@ -38,8 +38,10 @@ mumh5 keeps the foundation and replaces the experience.
| Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer | | Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer |
| Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost | | Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost |
| Right-click on mute or deafen | Nothing | Quick device and volume options | | Right-click on mute or deafen | Nothing | Quick device and volume options |
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
**Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording and the ACL editor. These are on the roadmap below. **Where the desktop client is still ahead, today:** UDP voice transport (mumh5 sends voice through the encrypted TCP connection for now, which adds a little delay on bad networks), system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below.
--- ---
@@ -67,6 +69,9 @@ mumh5 keeps the foundation and replaces the experience.
- Change your name (mumh5 reconnects for you) and register on a server with one click - Change your name (mumh5 reconnects for you) and register on a server with one click
- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain - Connection information: ping, packet loss, client version and, for admins, the user's certificate chain
- Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register - Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register
- Channel management: create, edit, delete, link, and drag and drop channels and people
- Full permission editor: rules per channel with inheritance, groups and members, like the desktop ACL editor
- If you get kicked, you see who kicked you and why, with a Reconnect button
![Profile with a formatted description](docs/screenshots/profile.png) ![Profile with a formatted description](docs/screenshots/profile.png)
@@ -79,8 +84,20 @@ mumh5 keeps the foundation and replaces the experience.
- 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
### Color schemes
Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme with beveled buttons, navy title bars and the teal desktop.
| Windows 95 | Light | Frost | Midnight |
|---|---|---|---|
| ![Windows 95](docs/screenshots/theme-win95.png) | ![Light](docs/screenshots/theme-light.png) | ![Frost](docs/screenshots/theme-frost.png) | ![Midnight](docs/screenshots/theme-midnight.png) |
### Sounds
- Notification sounds for joins, leaves, messages, mute, push to talk, kicks and more
- Replace any sound with your own file, or load a whole sound pack at once (matched by file name)
### Servers and identity ### Servers and identity
- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts - Server rail with custom icons, drag-and-drop ordering, connection status and unread counts
- Public server browser with live user counts and ping, like the desktop client
- Several servers connected at once; voice follows the channel you join - Several servers connected at once; voice follows the channel you join
- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity - Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity
- Password-protected backups of your identity - Password-protected backups of your identity
Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

+31 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, shell, session, systemPreferences, type WebContents } from 'electron'; import { app, BrowserWindow, dialog, ipcMain, net, shell, session, systemPreferences, type WebContents } from 'electron';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -6,6 +6,7 @@ import { IdentityStore } from './identity-store.ts';
import { describeCert } from './certs.ts'; import { describeCert } from './certs.ts';
import * as tray from './tray.ts'; import * as tray from './tray.ts';
import { fetchLinkPreview } from './link-preview.ts'; import { fetchLinkPreview } from './link-preview.ts';
import { fetchPublicListWith, pingServer } from './publist.ts';
import { openTls } from './tls-transport.ts'; import { openTls } from './tls-transport.ts';
const devUrl = process.env.VITE_DEV_SERVER_URL; const devUrl = process.env.VITE_DEV_SERVER_URL;
@@ -75,6 +76,26 @@ ipcMain.on('mumble:close', (e, connId: string) => {
ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) => ipcMain.handle('certs:describe', (_e, ders: Uint8Array[]) =>
(Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } })); (Array.isArray(ders) ? ders.slice(0, 8) : []).flatMap(d => { try { return [describeCert(Buffer.from(d))]; } catch { return []; } }));
// Edit commands for the renderer's context menu (clipboard access goes through the browser engine)
ipcMain.on('edit:action', (e, action: string, arg?: unknown) => {
const wc = e.sender;
switch (action) {
case 'undo': return wc.undo();
case 'redo': return wc.redo();
case 'cut': return wc.cut();
case 'copy': return wc.copy();
case 'paste': return wc.paste();
case 'selectAll': return wc.selectAll();
case 'replaceMisspelling': return wc.replaceMisspelling(String(arg));
case 'addWord': return wc.session.addWordToSpellCheckerDictionary(String(arg));
case 'copyImageAt': { const p = arg as { x: number; y: number }; return wc.copyImageAt(p.x, p.y); }
case 'saveImage': return wc.downloadURL(String(arg));
}
});
ipcMain.handle('publist:fetch', (_e, url: string) => fetchPublicListWith(u => net.fetch(u), String(url)));
ipcMain.handle('publist:ping', (_e, host: string, port: number) => pingServer(String(host), Number(port)));
ipcMain.handle('preview:fetch', (_e, url: string) => fetchLinkPreview(String(url).slice(0, 2048))); 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.on('tray:update', (_e, state: tray.TrayState) => tray.update(state));
@@ -124,6 +145,15 @@ function createWindow() {
win.hide(); win.hide();
} }
}); });
// No default menu in Electron: report what was right-clicked, the renderer shows its own menu
// (custom menus call preventDefault, which suppresses this event)
win.webContents.on('context-menu', (_e, p) => {
win.webContents.send('context-menu', {
x: p.x, y: p.y, isEditable: p.isEditable, selectionText: p.selectionText, linkURL: p.linkURL,
mediaType: p.mediaType, srcURL: p.srcURL, misspelledWord: p.misspelledWord,
suggestions: p.dictionarySuggestions.slice(0, 5), editFlags: p.editFlags
});
});
win.on('show', () => tray.onWindowVisibility()); win.on('show', () => tray.onWindowVisibility());
win.on('hide', () => tray.onWindowVisibility()); win.on('hide', () => tray.onWindowVisibility());
win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); win.on('closed', () => { if (mainWindow === win) mainWindow = null; });
+4
View File
@@ -13,6 +13,10 @@ contextBridge.exposeInMainWorld('mumh5Native', {
platformInfo: () => ipcRenderer.invoke('platform:info'), platformInfo: () => ipcRenderer.invoke('platform:info'),
updateTray: (state: unknown) => ipcRenderer.send('tray:update', state), updateTray: (state: unknown) => ipcRenderer.send('tray:update', state),
linkPreview: (url: string) => ipcRenderer.invoke('preview:fetch', url), linkPreview: (url: string) => ipcRenderer.invoke('preview:fetch', url),
publicServers: (url: string) => ipcRenderer.invoke('publist:fetch', url),
pingServer: (host: string, port: number) => ipcRenderer.invoke('publist:ping', host, port),
onContextMenu: (fn: (params: unknown) => void) => { ipcRenderer.on('context-menu', (_e, p) => fn(p)); },
editAction: (action: string, arg?: unknown) => ipcRenderer.send('edit:action', action, arg),
onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); }, onTrayAction: (fn: (action: string) => void) => { ipcRenderer.on('tray:action', (_e, a: string) => fn(a)); },
describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders), describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders),
identities: { identities: {
+98
View File
@@ -0,0 +1,98 @@
import dgram from 'node:dgram';
import dns from 'node:dns/promises';
import { randomBytes } from 'node:crypto';
// Mumble's public server list and the classic UDP ping, as used by the desktop client.
export interface PublicServer {
name: string;
host: string;
port: number;
url: string;
country: string;
countryCode: string;
continent: string;
ca: boolean;
}
export interface PingResult {
ping: number; // round trip in ms
users: number;
maxUsers: number;
bandwidth: number;
version: string;
}
const decode = (s: string) => s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
// Website field: often a bare host like "www.example.com"; only http(s) links are kept
function webUrl(raw: string): string {
const v = raw.trim();
if (/^https?:\/\/[^\s]+$/i.test(v)) return v;
if (/^[a-z0-9-]+(\.[a-z0-9-]+)+(:\d+)?(\/\S*)?$/i.test(v)) return `http://${v}`;
return '';
}
export function parsePublicList(xml: string): PublicServer[] {
const out: PublicServer[] = [];
// Quoted values may contain '>' (e.g. name="<C>"), so step over quoted strings
for (const [, attrs] of xml.matchAll(/<server\s+((?:[^>"]|"[^"]*")*?)\/?>/g)) {
const a: Record<string, string> = {};
for (const m of attrs.matchAll(/([a-z_]+)="([^"]*)"/g)) a[m[1]] = decode(m[2]);
const port = Number(a.port) || 64738;
if (!a.ip || !a.name) continue;
out.push({
name: a.name, host: a.ip, port, url: webUrl(a.url ?? ''),
country: a.country || 'Unknown', countryCode: (a.country_code ?? '').toLowerCase(),
continent: (a.continent_code ?? '').toLowerCase(), ca: a.ca === '1'
});
}
return out;
}
// Mumble's list sits behind a CDN that refuses non-browser TLS clients (Node, curl: HTTP 501),
// so the app passes Electron's net.fetch, which uses Chromium's network stack
export async function fetchPublicListWith(fetcher: (url: string) => Promise<Response>, url: string): Promise<PublicServer[]> {
let res: Response;
try { res = await fetcher(url); } catch (e) { throw new Error(`Could not reach the list server (${(e as Error).message})`); }
if (!res.ok) throw new Error(`The list server answered with HTTP ${res.status}`);
return parsePublicList(await res.text());
}
// Legacy extended ping: 4 zero bytes + 8-byte cookie; answer is 24 bytes:
// version (legacy format), cookie, users, max users, bandwidth (big endian)
export async function pingServer(host: string, port: number, timeoutMs = 2000): Promise<PingResult | null> {
let address: string, family: number;
try { ({ address, family } = await dns.lookup(host)); } catch { return null; }
return new Promise(resolve => {
const sock = dgram.createSocket(family === 6 ? 'udp6' : 'udp4');
const cookie = randomBytes(8);
const packet = Buffer.concat([Buffer.alloc(4), cookie]);
let done = false;
const finish = (r: PingResult | null) => {
if (done) return;
done = true;
clearTimeout(timer);
try { sock.close(); } catch { /* closed */ }
resolve(r);
};
const start = process.hrtime.bigint();
const timer = setTimeout(() => finish(null), timeoutMs);
sock.on('message', msg => {
if (msg.length !== 24 || !msg.subarray(4, 12).equals(cookie)) return;
const v = msg.readUInt32BE(0);
finish({
ping: Number(process.hrtime.bigint() - start) / 1e6,
users: msg.readUInt32BE(12),
maxUsers: msg.readUInt32BE(16),
bandwidth: msg.readUInt32BE(20),
version: `${v >> 16}.${(v >> 8) & 255}.${v & 255}`
});
});
sock.on('error', () => finish(null));
sock.send(packet, port, address, err => { if (err) finish(null); });
});
}
+17 -3
View File
@@ -12,10 +12,12 @@
import IdentityWizard from './ui/IdentityWizard.svelte'; import IdentityWizard from './ui/IdentityWizard.svelte';
import QuickAudio from './ui/QuickAudio.svelte'; import QuickAudio from './ui/QuickAudio.svelte';
import UserInfoDialog from './ui/UserInfoDialog.svelte'; import UserInfoDialog from './ui/UserInfoDialog.svelte';
import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.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';
import { ui } from './lib/ui.svelte.ts'; import { ui } from './lib/ui.svelte.ts';
import type { SavedServer } from './lib/settings.svelte.ts'; import { store, type SavedServer } from './lib/settings.svelte.ts';
let width = $state(window.innerWidth); let width = $state(window.innerWidth);
// wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers // wide: rail + sidebar + chat + panel; medium: panel as an overlay; narrow: everything but chat in drawers
@@ -26,6 +28,13 @@
const showPanel = $derived(ui.panelOpen && session.status === 'connected'); const showPanel = $derived(ui.panelOpen && session.status === 'connected');
// Color scheme on the root element; dark is the stylesheet default
$effect(() => {
const t = store.settings.theme;
if (t && t !== 'dark') document.documentElement.dataset.theme = t;
else delete document.documentElement.dataset.theme;
});
// The panel starts open on wide layouts only // The panel starts open on wide layouts only
let lastLayout = ''; let lastLayout = '';
$effect(() => { $effect(() => {
@@ -53,9 +62,9 @@
<div class="app {layout}"> <div class="app {layout}">
{#if layout !== 'narrow' || navOpen} {#if layout !== 'narrow' || navOpen}
<div class="nav" class:drawer={layout === 'narrow'}> <div class="nav" class:drawer={layout === 'narrow'}>
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} <ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
onsettings={() => (ui.settingsOpen = true)} /> onsettings={() => (ui.settingsOpen = true)} />
<Sidebar onnavigate={() => { if (layout === 'narrow') navOpen = false; }} /> <Sidebar resizable={layout !== 'narrow'} onnavigate={() => { if (layout === 'narrow') navOpen = false; }} />
</div> </div>
{#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if} {#if layout === 'narrow'}<button class="scrim" aria-label="Close" onclick={() => (navOpen = false)}></button>{/if}
{/if} {/if}
@@ -73,6 +82,8 @@
</div> </div>
{#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.publicServers}<PublicServers onclose={() => (ui.publicServers = 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'}
@@ -82,6 +93,9 @@
{#if ui.cert}<CertViewer {...ui.cert} onclose={() => (ui.cert = null)} />{/if} {#if ui.cert}<CertViewer {...ui.cert} onclose={() => (ui.cert = null)} />{/if}
{#if identities.loaded && !identities.setupDone}<IdentityWizard mode="setup" onclose={() => {}} /> {#if identities.loaded && !identities.setupDone}<IdentityWizard mode="setup" onclose={() => {}} />
{:else if ui.wizard}<IdentityWizard mode={ui.wizard} onclose={() => (ui.wizard = null)} />{/if} {:else if ui.wizard}<IdentityWizard mode={ui.wizard} onclose={() => (ui.wizard = null)} />{/if}
{#if ui.channelDialog && session.status === 'connected'}
{#key ui.channelDialog}<ChannelDialog {...ui.channelDialog} onclose={() => (ui.channelDialog = null)} />{/key}
{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if} {#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => (ui.prompt = null)} />{/if}
<ContextMenu /> <ContextMenu />
+2
View File
@@ -20,6 +20,8 @@
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif; --font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif;
--mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace; --mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
--fs: 15px; --fs: 15px;
/* Shared height of the bottom bars: your user panel and the message input line up */
--footer-h: 52px;
color-scheme: dark; color-scheme: dark;
} }
+139
View File
@@ -102,6 +102,40 @@ export function describeDenial(d: Denial, channelName?: string): string {
} }
} }
// ACL permission bits (ChanACL::Perm)
export const PERM = {
Write: 0x1, Traverse: 0x2, Enter: 0x4, Speak: 0x8, MuteDeafen: 0x10, Move: 0x20,
MakeChannel: 0x40, LinkChannel: 0x80, Whisper: 0x100, TextMessage: 0x200, MakeTempChannel: 0x400,
Listen: 0x800, Kick: 0x10000, Ban: 0x20000, Register: 0x40000, SelfRegister: 0x80000, ResetUserContent: 0x100000
} as const;
export interface AclGroup {
name: string;
inherited: boolean; // exists because a parent defines it (and made it inheritable)
inherit: boolean; // members of the parent's group count here too
inheritable: boolean; // subchannels may inherit this group
add: number[]; // user ids added here
remove: number[]; // user ids removed here
inheritedMembers: number[];
}
export interface AclEntry {
applyHere: boolean;
applySubs: boolean;
inherited: boolean; // defined on a parent channel, read-only here
userId: number | null;
group: string | null;
grant: number;
deny: number;
}
export interface ChannelAcl {
channelId: number;
inheritAcls: boolean;
groups: AclGroup[];
acls: AclEntry[];
}
export interface ConnectOptions { export interface ConnectOptions {
username: string; username: string;
password?: string; password?: string;
@@ -122,6 +156,9 @@ type ClientEvents = {
reject: (type: number, reason: string) => void; reject: (type: number, reason: string) => void;
voice: (packet: Uint8Array) => void; voice: (packet: Uint8Array) => void;
userStats: (stats: any) => void; userStats: (stats: any) => void;
acl: (acl: ChannelAcl) => void;
userNames: (names: Map<number, string>) => void;
permissions: (channelId: number | null, bits: number) => void;
ping: (rttMs: number) => void; ping: (rttMs: number) => void;
close: (reason: string) => void; close: (reason: string) => void;
message: (name: MessageName, msg: any) => void; message: (name: MessageName, msg: any) => void;
@@ -130,6 +167,10 @@ type ClientEvents = {
export class MumbleClient extends Emitter<ClientEvents> { export class MumbleClient extends Emitter<ClientEvents> {
readonly channels = new Map<number, Channel>(); readonly channels = new Map<number, Channel>();
readonly users = new Map<number, User>(); readonly users = new Map<number, User>();
// Our effective permissions per channel (PermissionQuery); cleared when the server flushes
readonly permissions = new Map<number, number>();
// Registered user names by id, learned from QueryUsers
readonly registeredNames = new Map<number, string>();
session: number | null = null; session: number | null = null;
synced = false; synced = false;
welcomeText = ''; welcomeText = '';
@@ -276,6 +317,74 @@ export class MumbleClient extends Emitter<ClientEvents> {
this.send('UserState', { session, ...state }); this.send('UserState', { session, ...state });
} }
// ─── Channel management ────────────────────────────────────────────────────
requestPermissions(channelId: number): void {
this.send('PermissionQuery', { channel_id: channelId });
}
can(channelId: number, bit: number): boolean | null {
// The server never sends SuperUser (user id 0) its permissions: it may do everything
if (this.self?.userId === 0) return true;
const p = this.permissions.get(channelId);
// Write on a channel implies the other channel permissions
return p == null ? null : (p & (bit | PERM.Write)) !== 0;
}
createChannel(parent: number, name: string, opts: { temporary?: boolean; description?: string; position?: number; maxUsers?: number } = {}): void {
this.send('ChannelState', {
parent, name,
temporary: !!opts.temporary,
...(opts.description ? { description: opts.description } : {}),
...(opts.position ? { position: opts.position } : {}),
...(opts.maxUsers ? { max_users: opts.maxUsers } : {})
});
}
updateChannel(channelId: number, fields: { name?: string; description?: string; position?: number; maxUsers?: number; parent?: number }): void {
const msg: Record<string, unknown> = { channel_id: channelId };
if (fields.name != null) msg.name = fields.name;
if (fields.description != null) msg.description = fields.description;
if (fields.position != null) msg.position = fields.position;
if (fields.maxUsers != null) msg.max_users = fields.maxUsers;
if (fields.parent != null) msg.parent = fields.parent;
this.send('ChannelState', msg);
}
removeChannel(channelId: number): void {
this.send('ChannelRemove', { channel_id: channelId });
}
setLinks(channelId: number, add: number[], remove: number[]): void {
this.send('ChannelState', { channel_id: channelId, links_add: add, links_remove: remove });
}
queryAcl(channelId: number): void {
this.send('ACL', { channel_id: channelId, query: true });
}
// Sends the channel's own rules and groups; inherited ones stay with their channel
saveAcl(acl: ChannelAcl): void {
this.send('ACL', {
channel_id: acl.channelId,
inherit_acls: acl.inheritAcls,
groups: acl.groups
// Same rule as the desktop client: skip groups that only exist by inheritance
.filter(g => !(g.inherited && g.inherit && g.inheritable && !g.add.length && !g.remove.length))
.map(g => ({ name: g.name, inherit: g.inherit, inheritable: g.inheritable, add: g.add, remove: g.remove })),
acls: acl.acls.filter(a => !a.inherited).map(a => ({
apply_here: a.applyHere, apply_subs: a.applySubs,
...(a.userId != null ? { user_id: a.userId } : { group: a.group ?? 'all' }),
grant: a.grant, deny: a.deny
}))
});
}
// Resolves registered user ids to names and names to ids
queryUsers(q: { ids?: number[]; names?: string[] }): void {
this.send('QueryUsers', { ids: q.ids ?? [], names: q.names ?? [] });
}
// Connection details of a user; the server decides how much we may see // Connection details of a user; the server decides how much we may see
requestUserStats(session: number, statsOnly = false): void { requestUserStats(session: number, statsOnly = false): void {
this.send('UserStats', { session, stats_only: statsOnly }); this.send('UserStats', { session, stats_only: statsOnly });
@@ -367,6 +476,7 @@ export class MumbleClient extends Emitter<ClientEvents> {
this.session = msg.session; this.session = msg.session;
this.maxBandwidth = msg.max_bandwidth ?? 0; this.maxBandwidth = msg.max_bandwidth ?? 0;
this.welcomeText = msg.welcome_text ?? ''; this.welcomeText = msg.welcome_text ?? '';
if (msg.permissions != null) this.permissions.set(0, Number(msg.permissions));
this.synced = true; this.synced = true;
this.ping(); this.ping();
this.emit('synced'); this.emit('synced');
@@ -419,6 +529,35 @@ export class MumbleClient extends Emitter<ClientEvents> {
case 'UserStats': case 'UserStats':
this.emit('userStats', msg); this.emit('userStats', msg);
break; break;
case 'ACL': {
// proto2 defaults: missing booleans are true
const t = (v: unknown) => v !== false;
this.emit('acl', {
channelId: msg.channel_id,
inheritAcls: t(msg.inherit_acls),
groups: (msg.groups ?? []).map((g: any) => ({
name: g.name, inherited: t(g.inherited), inherit: t(g.inherit), inheritable: t(g.inheritable),
add: g.add ?? [], remove: g.remove ?? [], inheritedMembers: g.inherited_members ?? []
})),
acls: (msg.acls ?? []).map((a: any) => ({
applyHere: t(a.apply_here), applySubs: t(a.apply_subs), inherited: t(a.inherited),
userId: a.user_id ?? null, group: a.user_id != null ? null : (a.group ?? 'all'),
grant: a.grant ?? 0, deny: a.deny ?? 0
}))
});
break;
}
case 'QueryUsers': {
const ids: number[] = msg.ids ?? [], names: string[] = msg.names ?? [];
ids.forEach((id, i) => { if (names[i]) this.registeredNames.set(id, names[i]); });
this.emit('userNames', this.registeredNames);
break;
}
case 'PermissionQuery':
if (msg.flush) this.permissions.clear();
if (msg.channel_id != null && msg.permissions != null) this.permissions.set(msg.channel_id, msg.permissions);
this.emit('permissions', msg.channel_id ?? null, msg.permissions ?? 0);
break;
case 'PluginDataTransmission': case 'PluginDataTransmission':
if (msg.senderSession != null && msg.dataID) { if (msg.senderSession != null && msg.dataID) {
this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0)); this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0));
+32 -2
View File
@@ -2,13 +2,41 @@ import { session } from './session.svelte.ts';
import { ui } from './ui.svelte.ts'; import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts'; import { voice, userKey } from './audio/voice.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
import type { MenuItem } from './menu.svelte.ts'; import type { MenuItem } from './menu.svelte.ts';
import type { Channel, User } from '../core/client.ts'; import { PERM, type Channel, type User } from '../core/client.ts';
// Right-click menu for a channel in the tree // Right-click menu for a channel in the tree
export function channelMenu(c: Channel): MenuItem[] { export function channelMenu(c: Channel): MenuItem[] {
const here = session.self?.channelId === c.id; const here = session.self?.channelId === c.id;
const mine = session.self?.channelId ?? 0;
// Greyed out only when the server said no; unknown permissions stay enabled
const no = (bit: number) => session.can(c.id, bit) === false;
const linked = c.links.has(mine);
const manage: MenuItem[] = [
{ separator: true, label: '' },
{ label: 'Create channel...', disabled: no(PERM.MakeChannel) && no(PERM.MakeTempChannel), action: () => (ui.channelDialog = { mode: 'create', parent: c.id }) },
{ label: 'Edit channel...', disabled: no(PERM.Write), action: () => (ui.channelDialog = { mode: 'edit', id: c.id }) },
{ label: 'Permissions...', disabled: no(PERM.Write), action: () => (ui.channelDialog = { mode: 'edit', id: c.id, tab: 'permissions' }) }
];
if (!here) {
manage.push(linked
? { label: 'Unlink from my channel', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [], [mine]) }
: { label: 'Link to my channel', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [mine], []) });
}
if (c.links.size) manage.push({ label: 'Unlink all', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [], [...c.links]) });
if (c.id !== 0) {
manage.push({
label: 'Delete channel...', danger: true, disabled: no(PERM.Write),
action: () => (ui.prompt = {
title: `Delete ${c.name}?`, label: '', confirm: 'Delete channel', danger: true, input: 'none',
text: 'The channel, its subchannels and their permissions are removed for everyone. People inside are moved out.',
onsubmit: () => { session.client?.removeChannel(c.id); sounds.play('channelDeleted'); }
})
});
}
return [ return [
{ header: true, label: c.name || 'Root' },
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }, { label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
@@ -17,7 +45,8 @@ export function channelMenu(c: Channel): MenuItem[] {
{ label: 'Expand all', action: () => session.setAllExpanded(true) }, { label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) }, { label: 'Collapse all', action: () => session.setAllExpanded(false) },
{ separator: true, label: '' }, { separator: true, label: '' },
{ label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') } { label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') },
...manage
]; ];
} }
@@ -27,6 +56,7 @@ export function userMenu(u: User): MenuItem[] {
const self = session.self; const self = session.self;
const isSelf = u.session === self?.session; const isSelf = u.session === self?.session;
const items: MenuItem[] = [ const items: MenuItem[] = [
{ header: true, label: store.displayName(u) },
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } } { label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } }
]; ];
if (isSelf) { if (isSelf) {
+308
View File
@@ -0,0 +1,308 @@
import { load, save } from '../storage.ts';
import { voice } from './voice.svelte.ts';
import { putBlob, getBlob, deleteBlob } from '../blobstore.ts';
// Notification sounds for events, like the desktop client. Every event has a synthesized
// default; users can replace any of them with their own file (or a whole sound pack).
// ts: the TeamSpeak 3 sound pack key for the same event, so a TS pack's settings.ini maps exactly.
// keys: file name fragments for packs without a settings.ini.
export const SOUND_EVENTS = [
{ id: 'connected', group: 'Connection', name: 'You connected', ts: 'CONNECTION_CONNECTED', keys: ['connected_welcome', 'connected', 'login', 'welcome'] },
{ id: 'disconnected', group: 'Connection', name: 'You disconnected', ts: 'CONNECTION_DISCONNECTED', keys: ['disconnected', 'disconnect', 'logout'] },
{ id: 'connectionLost', group: 'Connection', name: 'Connection lost', ts: 'CONNECTION_LOST_CONNECTION', keys: ['connection_lost', 'lost'] },
{ id: 'error', group: 'Connection', name: 'Server refused or error', ts: 'SERVER_ERROR', keys: ['error'] },
{ id: 'switchedChannel', group: 'You', name: 'You switched channel', ts: 'YOU_SWITCHED_CHANNEL', keys: ['channel_switched', 'switched'] },
{ id: 'moved', group: 'You', name: 'You were moved', ts: 'YOU_WERE_MOVED_TO_DIFFERENT_CHANNEL', keys: ['you_were_moved', 'moved'] },
{ id: 'kicked', group: 'You', name: 'You were kicked', ts: 'YOU_WERE_KICKED_FROM_SERVER', keys: ['you_kicked_server', 'kicked'] },
{ id: 'banned', group: 'You', name: 'You were banned', ts: 'YOU_WERE_BANNED', keys: ['you_were_banned', 'banned'] },
{ id: 'denied', group: 'You', name: 'Permission denied', ts: 'SERVER_INSUFFICIENT_PERMISSIONS', keys: ['insufficient_permissions', 'denied'] },
{ id: 'userConnected', group: 'Your channel', name: 'Someone connected into your channel', ts: 'CLIENT_CONNECTION_CONNECTED_CURRENT_CHANNEL', keys: ['connection_connected_currentchannel', 'user_connected'] },
{ id: 'userDisconnected', group: 'Your channel', name: 'Someone in your channel disconnected', ts: 'CLIENT_CONNECTION_DISCONNECTED_CURRENT_CHANNEL', keys: ['connection_disconnected_currentchannel', 'user_disconnected'] },
{ id: 'userJoined', group: 'Your channel', name: 'Someone joined your channel', ts: 'CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS', keys: ['switched_tocurrentchannel', 'user_joined', 'joined', 'join'] },
{ id: 'userLeft', group: 'Your channel', name: 'Someone left your channel', ts: 'CLIENT_SWITCHED_FROM_CURRENT_CHANNEL_DISAPPEARS', keys: ['switched_awayfromcurrentchannel', 'user_left', 'left', 'leave'] },
{ id: 'userMovedIn', group: 'Your channel', name: 'Someone was moved into your channel', ts: 'CLIENT_MOVED_TO_CURRENT_CHANNEL_APPEARS', keys: ['moved_tocurrentchannel'] },
{ id: 'userMovedOut', group: 'Your channel', name: 'Someone was moved out of your channel', ts: 'CLIENT_MOVED_FROM_CURRENT_CHANNEL_DISAPPEARS', keys: ['moved_awayfromcurrentchannel'] },
{ id: 'userKicked', group: 'Your channel', name: 'Someone in your channel was kicked', ts: 'CLIENT_WAS_KICKED_FROM_SERVER_CURRENT_CHANNEL', keys: ['kicked_server_currentchannel'] },
{ id: 'userBanned', group: 'Your channel', name: 'Someone in your channel was banned', ts: 'CLIENT_WAS_BANNED_CURRENT_CHANNEL', keys: ['banned_server_currentchannel'] },
{ id: 'recordingStarted', group: 'Your channel', name: 'Someone started recording', ts: 'CLIENT_RECORDING_START', keys: ['recording_started'] },
{ id: 'recordingStopped', group: 'Your channel', name: 'Someone stopped recording', ts: 'CLIENT_RECORDING_STOP', keys: ['recording_stopped'] },
{ id: 'message', group: 'Chat', name: 'Channel message', ts: 'CHAT_RECEIVED_MESSAGE_CHANNEL', keys: ['chat_message_inbound', 'message'] },
{ id: 'privateMessage', group: 'Chat', name: 'Direct message', ts: 'CHAT_RECEIVED_MESSAGE_CLIENT', keys: ['private', 'direct', 'pm'] },
{ id: 'messageSent', group: 'Chat', name: 'You sent a message', ts: 'CHAT_SENT_MESSAGE_CHANNEL', keys: ['chat_message_outbound', 'sent'], off: true },
{ id: 'uploadDone', group: 'Chat', name: 'Upload finished', ts: 'OTHER_FILETRANSFER_COMPLETE', keys: ['filetransfer_complete', 'upload_done'] },
{ id: 'uploadFailed', group: 'Chat', name: 'Upload failed', ts: 'OTHER_FILETRANSFER_FAILED', keys: ['filetransfer_failed', 'upload_failed'] },
{ id: 'mute', group: 'Microphone and sound', name: 'You muted', ts: 'SOUND_CAPTURE_MUTED', keys: ['mic_muted', 'mute'] },
{ id: 'unmute', group: 'Microphone and sound', name: 'You unmuted', ts: 'SOUND_CAPTURE_UNMUTED', keys: ['mic_activated', 'unmute'] },
{ id: 'deafen', group: 'Microphone and sound', name: 'You deafened', ts: 'SOUND_PLAYBACK_MUTED', keys: ['sound_muted', 'deafen'] },
{ id: 'undeafen', group: 'Microphone and sound', name: 'You undeafened', ts: 'SOUND_PLAYBACK_UNMUTED', keys: ['sound_resumed', 'undeafen'] },
{ id: 'pttDown', group: 'Microphone and sound', name: 'Push to talk pressed', ts: 'SPECIAL_MIC_CLICK_SELF_ON', keys: ['mic_click_on', 'ptt_on'] },
{ id: 'pttUp', group: 'Microphone and sound', name: 'Push to talk released', ts: 'SPECIAL_MIC_CLICK_SELF_OFF', keys: ['mic_click_off', 'ptt_off'] },
{ id: 'talkingWhileMuted', group: 'Microphone and sound', name: 'You are talking while muted', ts: 'SPECIAL_TALKING_WHILE_MUTED', keys: ['stop_talking', 'talking_while_muted'] },
{ id: 'channelCreated', group: 'Channels', name: 'You created a channel', ts: 'CHANNEL_CREATED_BY_YOU', keys: ['channel_created'] },
{ id: 'channelDeleted', group: 'Channels', name: 'You deleted a channel', ts: 'CHANNEL_DELETED_BY_YOU', keys: ['channel_deleted'] },
{ id: 'channelEdited', group: 'Channels', name: 'You edited a channel', ts: 'CHANNEL_EDITED_CURRENT_BY_YOU', keys: ['channel_edited'] },
{ id: 'channelMoved', group: 'Channels', name: 'You moved a channel', ts: 'CHANNEL_MOVED_BY_YOU', keys: ['channel_moved'] }
] as const;
export type SoundEvent = (typeof SOUND_EVENTS)[number]['id'];
interface SoundSettings {
enabled: boolean;
volume: number;
// Explicit per-event choice; events missing here use their default (on, unless marked off)
on: Partial<Record<SoundEvent, boolean>>;
// File names only; the audio itself lives in IndexedDB (key "sound:<event>")
custom: Partial<Record<SoundEvent, { name: string; data?: string }>>;
}
const MAX_FILE = 1024 * 1024;
// Default tones: [frequency Hz, start s, length s] per event, with a short fade
const TONES: Record<SoundEvent, [number, number, number][]> = {
connected: [[523, 0, 0.09], [659, 0.09, 0.09], [784, 0.18, 0.16]],
disconnected: [[784, 0, 0.09], [659, 0.09, 0.09], [523, 0.18, 0.16]],
connectionLost: [[523, 0, 0.12], [392, 0.13, 0.12], [262, 0.26, 0.2]],
error: [[311, 0, 0.1], [277, 0.11, 0.14]],
switchedChannel: [[659, 0, 0.06], [784, 0.07, 0.08]],
moved: [[587, 0, 0.06], [880, 0.07, 0.06], [587, 0.14, 0.06]],
kicked: [[220, 0, 0.12], [196, 0.13, 0.2]],
banned: [[196, 0, 0.14], [165, 0.15, 0.24]],
denied: [[311, 0, 0.08], [277, 0.09, 0.1]],
userConnected: [[740, 0, 0.07], [988, 0.08, 0.1]],
userDisconnected: [[988, 0, 0.07], [740, 0.08, 0.1]],
userJoined: [[660, 0, 0.07], [880, 0.08, 0.1]],
userLeft: [[880, 0, 0.07], [660, 0.08, 0.1]],
userMovedIn: [[587, 0, 0.06], [880, 0.07, 0.08]],
userMovedOut: [[880, 0, 0.06], [587, 0.07, 0.08]],
userKicked: [[330, 0, 0.1], [262, 0.11, 0.12]],
userBanned: [[294, 0, 0.12], [220, 0.13, 0.16]],
recordingStarted: [[1175, 0, 0.08], [1175, 0.12, 0.08]],
recordingStopped: [[880, 0, 0.12]],
message: [[1047, 0, 0.06]],
privateMessage: [[1047, 0, 0.05], [1319, 0.07, 0.07]],
messageSent: [[1319, 0, 0.04]],
uploadDone: [[784, 0, 0.06], [1047, 0.07, 0.08]],
uploadFailed: [[392, 0, 0.08], [330, 0.09, 0.1]],
mute: [[392, 0, 0.07]],
unmute: [[784, 0, 0.07]],
deafen: [[330, 0, 0.06], [262, 0.07, 0.08]],
undeafen: [[262, 0, 0.06], [330, 0.07, 0.08]],
pttDown: [[1200, 0, 0.03]],
pttUp: [[900, 0, 0.03]],
talkingWhileMuted: [[440, 0, 0.08], [440, 0.12, 0.08]],
channelCreated: [[659, 0, 0.06], [988, 0.07, 0.08]],
channelDeleted: [[988, 0, 0.06], [659, 0.07, 0.08]],
channelEdited: [[784, 0, 0.08]],
channelMoved: [[659, 0, 0.06], [784, 0.07, 0.06]]
};
// TeamSpeak 3 sound pack settings.ini: KEY = play("file.wav"); ${clientType} picks a variant
export function parseTsPack(ini: string): Map<string, string> {
const out = new Map<string, string>();
for (const line of ini.split(/\r?\n/)) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*play\("([^"]+)"\)/.exec(line);
if (m) out.set(m[1], m[2]);
}
return out;
}
// Renders a tone sequence to a WAV blob URL, so defaults and custom files play the same way
async function renderTones(ev: SoundEvent): Promise<string> {
const notes = TONES[ev];
const rate = 44100;
const length = Math.ceil((Math.max(...notes.map(n => n[1] + n[2])) + 0.05) * rate);
const ctx = new OfflineAudioContext(1, length, rate);
for (const [freq, start, dur] of notes) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(0.35, start + 0.008);
gain.gain.setValueAtTime(0.35, start + dur - 0.02);
gain.gain.linearRampToValueAtTime(0, start + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(start);
osc.stop(start + dur);
}
const buf = await ctx.startRendering();
return URL.createObjectURL(toWav(buf.getChannelData(0), rate));
}
function toWav(samples: Float32Array, rate: number): Blob {
const out = new DataView(new ArrayBuffer(44 + samples.length * 2));
const str = (o: number, s: string) => [...s].forEach((c, i) => out.setUint8(o + i, c.charCodeAt(0)));
str(0, 'RIFF'); out.setUint32(4, 36 + samples.length * 2, true); str(8, 'WAVE');
str(12, 'fmt '); out.setUint32(16, 16, true); out.setUint16(20, 1, true); out.setUint16(22, 1, true);
out.setUint32(24, rate, true); out.setUint32(28, rate * 2, true); out.setUint16(32, 2, true); out.setUint16(34, 16, true);
str(36, 'data'); out.setUint32(40, samples.length * 2, true);
samples.forEach((v, i) => out.setInt16(44 + i * 2, Math.max(-1, Math.min(1, v)) * 0x7fff, true));
return new Blob([out.buffer], { type: 'audio/wav' });
}
class Sounds {
settings = $state<SoundSettings>(load('mumh5.sounds', { enabled: true, volume: 0.6, on: {}, custom: {} }));
private defaults = new Map<SoundEvent, Promise<string>>();
// Object URLs of custom sounds; blob: is what the content security policy allows for media
private urls = new Map<SoundEvent, string>();
private ready: Promise<void>;
errors = $state<string[]>([]);
constructor() {
voice.onPtt = down => this.play(down ? 'pttDown' : 'pttUp');
voice.onTalkingWhileMuted = () => this.play('talkingWhileMuted');
this.ready = this.loadStored();
}
// Loads custom sounds from IndexedDB; moves sounds from older versions out of localStorage
private async loadStored(): Promise<void> {
let migrated = false;
for (const [ev, c] of Object.entries(this.settings.custom) as [SoundEvent, { name: string; data?: string }][]) {
try {
if (c.data) {
// Decode directly: the content security policy does not allow fetching data: URLs
const [meta, b64] = c.data.split(',');
const bytes = Uint8Array.from(atob(b64), ch => ch.charCodeAt(0));
const blob = new Blob([bytes], { type: /^data:([^;]+)/.exec(meta)?.[1] ?? 'audio/wav' });
await putBlob(`sound:${ev}`, blob);
delete c.data;
migrated = true;
}
const blob = await getBlob(`sound:${ev}`);
if (blob) this.urls.set(ev, URL.createObjectURL(blob));
else delete this.settings.custom[ev];
} catch { /* storage unavailable */ }
}
if (migrated) this.persist();
}
private lastPlayed = new Map<SoundEvent, number>();
private persist(): void {
save('mumh5.sounds', $state.snapshot(this.settings));
}
update(patch: Partial<Pick<SoundSettings, 'enabled' | 'volume'>>): void {
Object.assign(this.settings, patch);
this.persist();
}
isOn(ev: SoundEvent): boolean {
const chosen = this.settings.on?.[ev];
if (chosen != null) return chosen;
return !(SOUND_EVENTS.find(e => e.id === ev) as { off?: boolean } | undefined)?.off;
}
setOn(ev: SoundEvent, on: boolean): void {
(this.settings.on ??= {})[ev] = on;
this.persist();
}
async setCustom(ev: SoundEvent, file: File): Promise<string | null> {
if (file.size > MAX_FILE) return `${file.name} is larger than 1 MB`;
if (!/^audio\//.test(file.type) && !/\.(wav|mp3|ogg|oga|opus|flac|m4a|aac|webm)$/i.test(file.name)) return `${file.name} is not an audio file`;
try {
await putBlob(`sound:${ev}`, file);
} catch (e) {
return `Could not store ${file.name}: ${(e as Error).message}`;
}
const old = this.urls.get(ev);
if (old) URL.revokeObjectURL(old);
this.urls.set(ev, URL.createObjectURL(file));
this.settings.custom[ev] = { name: file.name };
this.persist();
return null;
}
reset(ev: SoundEvent): void {
delete this.settings.custom[ev];
const url = this.urls.get(ev);
if (url) URL.revokeObjectURL(url);
this.urls.delete(ev);
deleteBlob(`sound:${ev}`).catch(() => {});
this.persist();
}
// A TeamSpeak 3 sound pack (folder with settings.ini) maps exactly; otherwise files are
// matched to events by name ("user_joined.wav" -> Someone joined your channel).
async loadPack(files: File[]): Promise<{ assigned: string[]; unmatched: string[] }> {
const assigned: string[] = [], unmatched: string[] = [];
const audio = files.filter(f => /\.(wav|mp3|ogg|oga|opus|flac|m4a|aac|webm)$/i.test(f.name));
const byName = new Map(audio.map(f => [f.name.toLowerCase(), f]));
const used = new Set<File>();
const ini = files.find(f => /settings\.ini$/i.test(f.name));
if (ini) {
const entries = parseTsPack(await ini.text());
for (const e of SOUND_EVENTS) {
const wanted = entries.get(e.ts);
if (!wanted) continue;
// ${clientType} variants: prefer "neutral", else whichever the pack has
const suffix = wanted.replace(/^.*\$\{clientType\}_?/, '').toLowerCase();
const file = wanted.includes('${clientType}')
? byName.get(`neutral_${suffix}`) ?? audio.find(f => f.name.toLowerCase().endsWith(`_${suffix}`))
: byName.get(wanted.toLowerCase());
if (file && !(await this.setCustom(e.id, file))) {
assigned.push(`${file.name}: ${e.name}`);
used.add(file);
}
}
for (const f of audio) if (!used.has(f) && !assigned.some(a => a.startsWith(f.name + ':'))) unmatched.push(f.name);
return { assigned, unmatched: [...new Set(unmatched)] };
}
for (const f of audio) {
const base = f.name.toLowerCase().replace(/\.[a-z0-9]+$/, '').replace(/[\s-]+/g, '_');
// Most specific match wins: exact key, then longest key contained in the name
let best: { ev: SoundEvent; score: number } | null = null;
for (const e of SOUND_EVENTS) {
for (const k of e.keys) {
const score = base === k ? 1000 : base.includes(k) ? k.length : 0;
if (score && (!best || score > best.score)) best = { ev: e.id, score };
}
}
if (best && !(await this.setCustom(best.ev, f))) assigned.push(`${f.name}: ${SOUND_EVENTS.find(e => e.id === best!.ev)!.name}`);
else unmatched.push(f.name);
}
return { assigned, unmatched };
}
resetAll(): void {
for (const ev of Object.keys(this.settings.custom) as SoundEvent[]) this.reset(ev);
}
private async source(ev: SoundEvent): Promise<string> {
await this.ready;
const custom = this.urls.get(ev);
if (custom) return custom;
let p = this.defaults.get(ev);
if (!p) this.defaults.set(ev, (p = renderTones(ev)));
return p;
}
// Plays an event sound through the voice output device; force plays even when disabled (preview)
async play(ev: SoundEvent, force = false): Promise<void> {
if (!force && (!this.settings.enabled || !this.isOn(ev))) return;
// Bursts of the same event (e.g. many people joining) play once
const now = performance.now();
if (!force && now - (this.lastPlayed.get(ev) ?? 0) < 150) return;
this.lastPlayed.set(ev, now);
try {
const el = new Audio(await this.source(ev)) as HTMLAudioElement & { setSinkId?: (id: string) => Promise<void> };
el.volume = Math.max(0, Math.min(1, this.settings.volume));
const sink = voice.settings.outputDevice;
if (sink && sink !== 'default' && el.setSinkId) await el.setSinkId(sink).catch(() => {});
await el.play();
// Inspection hook for the end-to-end tests: events that really started playing
const log = ((window as any).__mumh5Sounds ??= []) as string[];
log.push(ev);
if (log.length > 50) log.shift();
} catch (e) {
// Shown in the Sounds settings, so a broken file does not fail silently
const msg = `${SOUND_EVENTS.find(x => x.id === ev)?.name}: ${(e as Error).message}`;
if (!this.errors.includes(msg)) this.errors = [...this.errors.slice(-4), msg];
}
}
}
export const sounds = new Sounds();
+17
View File
@@ -80,6 +80,11 @@ class VoiceEngine {
private client: MumbleClient | null = null; private client: MumbleClient | null = null;
private sessionGains = new Map<number, number>(); private sessionGains = new Map<number, number>();
private unsubscribe: (() => void)[] = []; private unsubscribe: (() => void)[] = [];
// Push-to-talk press/release, for the notification sounds
onPtt: ((down: boolean) => void) | null = null;
// Speaking (or pressing push to talk) while muted, for the reminder sound
onTalkingWhileMuted: (() => void) | null = null;
private lastMutedReminder = 0;
private frameCounter = 0; // Mumble sequence, in 10 ms frames private frameCounter = 0; // Mumble sequence, in 10 ms frames
private sampleTime = 0; // encoder timestamps, microseconds private sampleTime = 0; // encoder timestamps, microseconds
@@ -301,6 +306,12 @@ class VoiceEngine {
if (db > s.vadThreshold) this.lastVoiceAt = now; if (db > s.vadThreshold) this.lastVoiceAt = now;
wanted = now - this.lastVoiceAt < s.vadHoldMs; wanted = now - this.lastVoiceAt < s.vadHoldMs;
} }
// Speaking into a muted microphone: remind, at most every 8 seconds
const self = this.client?.self;
if (wanted && s.mode === 'vad' && self && (self.selfMute || self.mute) && !self.selfDeaf && now - this.lastMutedReminder > 8000) {
this.lastMutedReminder = now;
this.onTalkingWhileMuted?.();
}
wanted &&= this.canTalk() && !!this.encoder && this.encoder.state === 'configured'; wanted &&= this.canTalk() && !!this.encoder && this.encoder.state === 'configured';
if (wanted && !this.open) { this.open = true; this.transmitting = true; } if (wanted && !this.open) { this.open = true; this.transmitting = true; }
@@ -353,7 +364,13 @@ class VoiceEngine {
// Typing in a text field does not trigger push to talk // Typing in a text field does not trigger push to talk
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (this.pttDown === down) return;
this.pttDown = down; this.pttDown = down;
if (this.settings.mode === 'ptt' && this.client) {
const self = this.client.self;
if (down && self && (self.selfMute || self.mute)) this.onTalkingWhileMuted?.();
else this.onPtt?.(down);
}
} }
// ─── Receiving ────────────────────────────────────────────────────────────── // ─── Receiving ──────────────────────────────────────────────────────────────
+24
View File
@@ -0,0 +1,24 @@
// Small IndexedDB store for user files (custom sounds): localStorage is too small for audio.
const DB = 'mumh5';
const STORE = 'files';
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function run<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(db => new Promise<T>((resolve, reject) => {
const req = fn(db.transaction(STORE, mode).objectStore(STORE));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}));
}
export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key));
export const getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(key));
export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key));
+2
View File
@@ -0,0 +1,2 @@
// What is being dragged in the channel tree
export const drag = $state<{ item: { kind: 'channel' | 'user'; id: number } | null }>({ item: null });
+24 -1
View File
@@ -160,9 +160,32 @@ function serialize(node: Node): string {
return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`; return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}</${tag}>`;
} }
// Turns plain URLs in text into links (not inside existing links)
function linkifyNodes(root: Node): void {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const texts: Text[] = [];
while (walker.nextNode()) texts.push(walker.currentNode as Text);
for (const t of texts) {
if ((t.parentElement as Element | null)?.closest('a')) continue;
const parts = (t.data).split(/(https?:\/\/[^\s<]+[^\s<.,:;"')\]])/);
if (parts.length < 2) continue;
const frag = document.createDocumentFragment();
parts.forEach((p, i) => {
if (i % 2) {
const a = document.createElement('a');
a.setAttribute('href', p);
a.textContent = p;
frag.append(a);
} else if (p) frag.append(p);
});
t.replaceWith(frag);
}
}
// Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render // Sanitized, well-formed XHTML that Murmur's XML check accepts and Mumble clients render
export function toMumbleHtml(html: string): string { export function toMumbleHtml(html: string, opts: { linkify?: boolean } = {}): string {
const frag = DOMPurify.sanitize(html, purifyConfig); const frag = DOMPurify.sanitize(html, purifyConfig);
if (opts.linkify) linkifyNodes(frag);
return [...frag.childNodes].map(serialize).join('') return [...frag.childNodes].map(serialize).join('')
.replace(/(<p><br\/><\/p>)+$/, '') .replace(/(<p><br\/><\/p>)+$/, '')
.replace(/^(<br\/>)+|(<br\/>)+$/g, '') .replace(/^(<br\/>)+|(<br\/>)+$/g, '')
+1
View File
@@ -21,6 +21,7 @@ export const ICON_PATHS: Record<string, string> = {
server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01', server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01',
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
download: 'M12 3v12M7 10l5 5 5-5M4 21h16', download: 'M12 3v12M7 10l5 5 5-5M4 21h16',
globe: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2c3.5 3.5 3.5 16.5 0 20M12 2c-3.5 3.5-3.5 16.5 0 20',
list: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01', list: 'M9 6h12M9 12h12M9 18h12M4 6h.01M4 12h.01M4 18h.01',
'list-ordered': 'M10 6h11M10 12h11M10 18h11M4 4v4M3 18h3l-3 3h3M3 10h2l-2 3h2', 'list-ordered': 'M10 6h11M10 12h11M10 18h11M4 4v4M3 18h3l-3 3h3M3 10h2l-2 3h2',
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1', link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
+8 -1
View File
@@ -5,13 +5,16 @@ export interface MenuItem {
checked?: boolean; checked?: boolean;
disabled?: boolean; disabled?: boolean;
separator?: boolean; separator?: boolean;
// Non-interactive title at the top (e.g. the server's name)
header?: boolean;
// Inline slider instead of a clickable item; the menu stays open while dragging // 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 }; 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 // One context menu for the whole app, opened at the pointer position
class MenuStore { class MenuStore {
open = $state<{ x: number; y: number; items: MenuItem[] } | null>(null); // keepFocus: leave focus (and the text selection) where it is, for text editing menus
open = $state<{ x: number; y: number; items: MenuItem[]; keepFocus?: boolean } | null>(null);
show(e: MouseEvent, items: MenuItem[]): void { show(e: MouseEvent, items: MenuItem[]): void {
e.preventDefault(); e.preventDefault();
@@ -19,6 +22,10 @@ class MenuStore {
this.open = { x: e.clientX, y: e.clientY, items }; this.open = { x: e.clientX, y: e.clientY, items };
} }
showAt(x: number, y: number, items: MenuItem[], keepFocus = false): void {
this.open = { x, y, items, keepFocus };
}
close(): void { close(): void {
this.open = null; this.open = null;
} }
+21
View File
@@ -6,6 +6,10 @@ interface NativeApi {
updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void; updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void;
onTrayAction(fn: (action: string) => void): void; onTrayAction(fn: (action: string) => void): void;
linkPreview(url: string): Promise<LinkPreviewData | null>; linkPreview(url: string): Promise<LinkPreviewData | null>;
publicServers(url: string): Promise<PublicServer[]>;
pingServer(host: string, port: number): Promise<PingResult | null>;
onContextMenu(fn: (params: ContextParams) => void): void;
editAction(action: string, arg?: unknown): void;
identities: { identities: {
list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>;
create(name: string, email: string): Promise<IdentitySummary>; create(name: string, email: string): Promise<IdentitySummary>;
@@ -24,6 +28,23 @@ interface NativeApi {
on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void; on(event: 'secure' | 'data' | 'close', fn: (connId: string, ...args: any[]) => void): () => void;
} }
// What Electron reports for a right-click without a custom menu
export interface ContextParams {
x: number;
y: number;
isEditable: boolean;
selectionText: string;
linkURL: string;
mediaType: string;
srcURL: string;
misspelledWord: string;
suggestions: string[];
editFlags: { canUndo: boolean; canRedo: boolean; canCut: boolean; canCopy: boolean; canPaste: boolean; canSelectAll: boolean };
}
export interface PublicServer { name: string; host: string; port: number; url: string; country: string; countryCode: string; continent: string; ca: boolean }
export interface PingResult { ping: number; users: number; maxUsers: number; bandwidth: number; version: string }
export interface LinkPreviewData { export interface LinkPreviewData {
url: string; url: string;
title: string; title: string;
+98 -3
View File
@@ -6,6 +6,7 @@ import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts'; import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts';
import { voice } from './audio/voice.svelte.ts'; import { voice } from './audio/voice.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
const codec = createCodec(); const codec = createCodec();
@@ -59,6 +60,11 @@ export class Session {
uploads = $state<UploadJob[]>([]); uploads = $state<UploadJob[]>([]);
certPrompt = $state<CertPrompt | null>(null); certPrompt = $state<CertPrompt | null>(null);
rtt = $state(0); rtt = $state(0);
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
// Last known channel per user, to tell joins from leaves of our channel for sounds
private lastChannel = new Map<number, number>();
// TLS details of the current server, for the certificate viewer and the trusted badge // TLS details of the current server, for the certificate viewer and the trusted badge
serverCert = $state<ServerCertInfo | null>(null); serverCert = $state<ServerCertInfo | null>(null);
uploadInfo = $state<UploadInfo | null>(null); uploadInfo = $state<UploadInfo | null>(null);
@@ -155,6 +161,22 @@ export class Session {
this.expanded = all; this.expanded = all;
} }
// true / false when known, null when the server has not told us yet
can(channelId: number, bit: number): boolean | null {
void this.tick;
return this.client?.can(channelId, bit) ?? null;
}
// Whether a channel lies somewhere below another (a channel cannot move into its own subtree)
isInside(channelId: number, ancestor: number): boolean {
let c = this.client?.channels.get(channelId);
while (c && c.parent != null) {
if (c.parent === ancestor) return true;
c = this.client?.channels.get(c.parent);
}
return false;
}
viewTitle(key: string = this.view): string { viewTitle(key: string = this.view): string {
const [kind, id] = key.split(':'); const [kind, id] = key.split(':');
if (kind === 'ch') return this.channel(Number(id))?.name || 'Root'; if (kind === 'ch') return this.channel(Number(id))?.name || 'Root';
@@ -181,6 +203,8 @@ export class Session {
this.panel = { kind: 'members' }; this.panel = { kind: 'members' };
this.requested.clear(); this.requested.clear();
this.serverCert = null; this.serverCert = null;
this.disconnectInfo = null;
this.removedBy = null;
this.status = 'connecting'; this.status = 'connecting';
if (!native) { if (!native) {
@@ -226,6 +250,10 @@ export class Session {
}); });
} }
reconnect(): void {
if (this.server) this.connect($state.snapshot(this.server) as SavedServer);
}
acceptCertificate(): void { acceptCertificate(): void {
const p = this.certPrompt; const p = this.certPrompt;
if (!p) return; if (!p) return;
@@ -258,10 +286,23 @@ export class Session {
this.status = 'connected'; this.status = 'connected';
this.view = `ch:${client.self?.channelId ?? 0}`; this.view = `ch:${client.self?.channelId ?? 0}`;
sessions.onSynced(this); sessions.onSynced(this);
sounds.play('connected');
for (const u of client.users.values()) this.lastChannel.set(u.session, u.channelId);
// What we may do per channel, for menus (not rate limited by the server)
for (const id of client.channels.keys()) client.requestPermissions(id);
if (client.welcomeText) this.system(this.view, client.welcomeText, true); if (client.welcomeText) this.system(this.view, client.welcomeText, true);
bump(); bump();
}); });
client.on('channel', bump); client.on('channel', ch => {
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
bump();
});
client.on('permissions', () => {
if (!live()) return;
// A flush means permissions changed somewhere (e.g. an ACL edit); ask again
if (!client.permissions.size) for (const id of client.channels.keys()) client.requestPermissions(id);
bump();
});
client.on('channelRemove', id => { client.on('channelRemove', id => {
if (!live()) return; if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' }; if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
@@ -269,9 +310,12 @@ export class Session {
}); });
client.on('ping', rtt => { if (live()) this.rtt = rtt; }); client.on('ping', rtt => { if (live()) this.rtt = rtt; });
client.on('user', (user, changed, _actor, isNew) => { client.on('user', (user, changed, actor, isNew) => {
if (!live()) return; if (!live()) return;
const self = client.self; const self = client.self;
const before = this.lastChannel.get(user.session);
this.lastChannel.set(user.session, user.channelId);
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
if (client.synced && self) { if (client.synced && self) {
if (isNew) { if (isNew) {
if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`); if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`);
@@ -294,7 +338,17 @@ export class Session {
client.on('userRemove', (user, actor, reason, ban) => { client.on('userRemove', (user, actor, reason, ban) => {
if (!live()) return; if (!live()) return;
// We were kicked or banned; the server closes the connection right after
if (user.session === client.session) {
const by = actor != null ? client.users.get(actor)?.name ?? '' : '';
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
}
const self = client.self; const self = client.self;
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
const byOther = actor != null && actor !== user.session;
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
}
this.lastChannel.delete(user.session);
if (self && user.channelId === self.channelId) { if (self && user.channelId === self.channelId) {
const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null; const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null;
const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected'; const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected';
@@ -304,7 +358,11 @@ export class Session {
bump(); bump();
}); });
client.on('text', msg => { if (live()) this.receive(client, msg); }); client.on('text', msg => {
if (!live()) return;
this.receive(client, msg);
if (msg.actor != null && msg.actor !== client.session) sounds.play(msg.channels.length || msg.trees.length ? 'message' : 'privateMessage');
});
client.on('permissionDenied', d => { client.on('permissionDenied', d => {
if (!live()) return; if (!live()) return;
@@ -317,12 +375,23 @@ export class Session {
return; return;
} }
if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null; if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null;
sounds.play('denied');
const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined; const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined;
this.system(this.view, escapeHtml(describeDenial(d, ch))); this.system(this.view, escapeHtml(describeDenial(d, ch)));
}); });
client.on('reject', (_type, reason) => {
if (!live()) return;
this.removedBy = null;
this.disconnectInfo = { kind: 'rejected', by: '', reason };
});
client.on('close', reason => { client.on('close', reason => {
if (this.client !== client) return; if (this.client !== client) return;
if (this.removedBy) this.disconnectInfo = this.removedBy;
else if (!this.disconnectInfo && reason !== 'Disconnected') this.disconnectInfo = { kind: 'lost', by: '', reason };
const kind = this.disconnectInfo?.kind;
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
this.client = null; this.client = null;
sessions.onClosed(this); sessions.onClosed(this);
this.status = 'idle'; this.status = 'idle';
@@ -331,6 +400,29 @@ export class Session {
}); });
} }
// Presence sounds only come from the server where voice runs; others would be noise
private isVoice(): boolean {
return sessions.voiceId === this.server?.id;
}
private userSound(isSelf: boolean, user: User, changed: string[], actor: number | null, isNew: boolean, before: number | undefined, mine: number): void {
if (isSelf) {
const byOther = actor != null && actor !== user.session;
if (changed.includes('channelId')) sounds.play(byOther ? 'moved' : 'switchedChannel');
if (!byOther && changed.includes('selfDeaf')) sounds.play(user.selfDeaf ? 'deafen' : 'undeafen');
else if (!byOther && changed.includes('selfMute')) sounds.play(user.selfMute ? 'mute' : 'unmute');
return;
}
const self = actor == null || actor === user.session;
if (isNew) {
if (user.channelId === mine) sounds.play('userConnected');
} else if (changed.includes('channelId')) {
if (user.channelId === mine) sounds.play(self ? 'userJoined' : 'userMovedIn');
else if (before === mine) sounds.play(self ? 'userLeft' : 'userMovedOut');
}
if (changed.includes('recording') && user.channelId === mine) sounds.play(user.recording ? 'recordingStarted' : 'recordingStopped');
}
// ─── Panels, descriptions, comments ───────────────────────────────────────── // ─── Panels, descriptions, comments ─────────────────────────────────────────
showChannel(id: number, tab: 'info' | 'chat' = 'info'): void { showChannel(id: number, tab: 'info' | 'chat' = 'info'): void {
@@ -472,6 +564,7 @@ export class Session {
return false; return false;
} }
client.sendText(this.target(key), html); client.sendText(this.target(key), html);
sounds.play('messageSent');
const rendered = renderIncoming(html, store.trustedHosts); const rendered = renderIncoming(html, store.trustedHosts);
this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered }); this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered });
return true; return true;
@@ -514,8 +607,10 @@ export class Session {
if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() }; if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() };
this.sendHtml(html, key); this.sendHtml(html, key);
this.uploads = this.uploads.filter(j => j.id !== job.id); this.uploads = this.uploads.filter(j => j.id !== job.id);
sounds.play('uploadDone');
} catch (e) { } catch (e) {
live().error = (e as Error).message; live().error = (e as Error).message;
sounds.play('uploadFailed');
setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000); setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000);
} }
} }
+15 -1
View File
@@ -21,8 +21,20 @@ export interface Settings {
youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail 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 // Link previews: fetched by the upload host (private), by this computer (reveals IP), or none
linkPreviews: 'host' | 'direct' | 'off'; linkPreviews: 'host' | 'direct' | 'off';
theme: ThemeId;
publicListUrl: string; // where the public server list comes from
} }
export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95';
export const THEMES: { id: ThemeId; name: string; note: string }[] = [
{ id: 'dark', name: 'Dark', note: 'The default' },
{ id: 'light', name: 'Light', note: 'For bright rooms' },
{ id: 'midnight', name: 'Midnight', note: 'True black, red accents' },
{ id: 'frost', name: 'Frost', note: 'Cool blue-grey' },
{ id: 'win95', name: 'Windows 95', note: 'Start me up' }
];
const defaults: Settings = { const defaults: Settings = {
uploadHost: '', uploadHost: '',
uploadKey: '', uploadKey: '',
@@ -31,7 +43,9 @@ const defaults: Settings = {
sendThumbnails: true, sendThumbnails: true,
compact: false, compact: false,
youtube: 'click', youtube: 'click',
linkPreviews: 'host' linkPreviews: 'host',
theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0'
}; };
class SettingsStore { class SettingsStore {
+63
View File
@@ -0,0 +1,63 @@
import { native } from './native.ts';
import { menu, type MenuItem } from './menu.svelte.ts';
// Right-click menu for text fields, messages, links and images. Elements with their own menu
// (users, channels, servers) call preventDefault, so Electron does not report those.
export function startTextMenu(): void {
if (!native) return;
const api = native;
api.onContextMenu(p => {
// Edit commands act on the focused element; keep it focused through the menu
const field = p.isEditable ? (document.activeElement as HTMLElement | null) : null;
const edit = (action: string, arg?: unknown) => { field?.focus(); api.editAction(action, arg); };
const items: MenuItem[] = [];
const add = (group: MenuItem[]) => {
if (!group.length) return;
if (items.length) items.push({ separator: true, label: '' });
items.push(...group);
};
if (p.misspelledWord) {
add([
...p.suggestions.map(s => ({ label: s, action: () => edit('replaceMisspelling', s) })),
...(p.suggestions.length ? [] : [{ label: 'No suggestions', disabled: true }]),
{ label: 'Add to dictionary', action: () => api.editAction('addWord', p.misspelledWord) }
]);
}
if (p.isEditable) {
const f = p.editFlags;
add([
{ label: 'Undo', disabled: !f.canUndo, action: () => edit('undo') },
{ label: 'Redo', disabled: !f.canRedo, action: () => edit('redo') }
]);
add([
{ label: 'Cut', disabled: !f.canCut, action: () => edit('cut') },
{ label: 'Copy', disabled: !f.canCopy, action: () => edit('copy') },
{ label: 'Paste', disabled: !f.canPaste, action: () => edit('paste') }
]);
add([{ label: 'Select all', disabled: !f.canSelectAll, action: () => edit('selectAll') }]);
} else {
if (p.selectionText.trim()) add([{ label: 'Copy', action: () => api.editAction('copy') }]);
if (p.linkURL && /^https?:/i.test(p.linkURL)) {
add([
{ label: 'Open link', action: () => window.open(p.linkURL, '_blank', 'noopener') },
{ label: 'Copy link address', action: () => navigator.clipboard.writeText(p.linkURL) }
]);
}
if (p.mediaType === 'image' && p.srcURL) {
add([
{ label: 'Copy image', action: () => api.editAction('copyImageAt', { x: p.x, y: p.y }) },
{ label: 'Save image...', action: () => api.editAction('saveImage', p.srcURL) }
]);
}
// The message under the pointer, as plain text
const msg = document.elementFromPoint(p.x, p.y)?.closest('.msg, .system');
const text = msg?.querySelector('.html')?.textContent?.trim();
if (text) add([{ label: 'Copy message text', action: () => navigator.clipboard.writeText(text) }]);
}
if (items.length) menu.showAt(p.x, p.y, items, true);
});
}
+6 -1
View File
@@ -25,7 +25,12 @@ class Ui {
wizard = $state<'setup' | 'add' | null>(null); wizard = $state<'setup' | 'add' | null>(null);
// Quick audio popover from right-clicking mute (input) or deafen (output) // Quick audio popover from right-clicking mute (input) or deafen (output)
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' | 'files' | 'identities'>('voice'); settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>('voice');
publicServers = $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`
channelDialog = $state<{ mode: 'create'; parent: number } | { mode: 'edit'; id: number; tab?: 'general' | 'permissions' | 'groups' } | null>(null);
// Session whose connection information is shown // Session whose connection information is shown
userInfo = $state<number | null>(null); userInfo = $state<number | null>(null);
// Set when a panel was opened on a layout where it is an overlay // Set when a panel was opened on a layout where it is an overlay
+4
View File
@@ -1,7 +1,11 @@
import { mount } from 'svelte'; import { mount } from 'svelte';
import './app.css'; import './app.css';
import App from './App.svelte'; import App from './App.svelte';
// After the components' styles, so themes override them
import './themes.css';
import { startTray } from './lib/tray.svelte.ts'; import { startTray } from './lib/tray.svelte.ts';
import { startTextMenu } from './lib/text-menu.ts';
mount(App, { target: document.getElementById('app')! }); mount(App, { target: document.getElementById('app')! });
startTray(); startTray();
startTextMenu();
+253
View File
@@ -0,0 +1,253 @@
/* Color schemes. Dark is the default in app.css; each theme overrides the same variables.
[data-theme-preview] applies a theme to a single element (the pickers in Settings). */
:root:not([data-theme]), [data-theme-preview="dark"] {
--bg-0: #0d0e10;
--bg-1: #151619;
--bg-2: #1b1c20;
--bg-3: #24262b;
--bg-hover: #2a2c32;
--bg-active: #33363d;
--line: #2c2e34;
--text: #e3e4e8;
--text-dim: #9a9ca5;
--text-faint: #686a73;
--accent: #7c6cff;
--accent-hover: #8f81ff;
--accent-text: #ffffff;
--speaking: #3bd16f;
--danger: #f0504a;
--warn: #e8b23c;
--link: #6fb3ff;
--rail: var(--bg-0);
color-scheme: dark;
}
:root[data-theme="light"], [data-theme-preview="light"] {
--rail: var(--bg-0);
--bg-0: #e3e5e8;
--bg-1: #f2f3f5;
--bg-2: #ffffff;
--bg-3: #e8e9ec;
--bg-hover: #e1e3e7;
--bg-active: #d4d7dc;
--line: #d4d6db;
--text: #1d1f23;
--text-dim: #555a64;
--text-faint: #858a94;
--accent: #5b4bdb;
--accent-hover: #4a3bc4;
--accent-text: #ffffff;
--speaking: #1f9d4c;
--danger: #d33a34;
--warn: #b7791f;
--link: #1f64c9;
color-scheme: light;
}
:root[data-theme="midnight"], [data-theme-preview="midnight"] {
--rail: var(--bg-0);
--bg-0: #000000;
--bg-1: #050505;
--bg-2: #0a0a0a;
--bg-3: #151515;
--bg-hover: #1a1a1a;
--bg-active: #222222;
--line: #1c1c1c;
--text: #d8d8d8;
--text-dim: #8a8a8a;
--text-faint: #5a5a5a;
--accent: #e0443e;
--accent-hover: #f05a54;
--accent-text: #ffffff;
--speaking: #3bd16f;
--danger: #ff5a52;
--warn: #e8b23c;
--link: #ff8a80;
color-scheme: dark;
}
:root[data-theme="frost"], [data-theme-preview="frost"] {
--rail: var(--bg-0);
--bg-0: #242933;
--bg-1: #2e3440;
--bg-2: #333a47;
--bg-3: #3b4252;
--bg-hover: #434c5e;
--bg-active: #4c566a;
--line: #3f4758;
--text: #eceff4;
--text-dim: #b4bccb;
--text-faint: #7f8a9e;
--accent: #88c0d0;
--accent-hover: #8fbcbb;
--accent-text: #1c222c;
--speaking: #a3be8c;
--danger: #bf616a;
--warn: #ebcb8b;
--link: #81a1c1;
color-scheme: dark;
}
/* ─── Windows 95 ───────────────────────────────────────────────────────────── */
:root[data-theme="win95"], [data-theme-preview="win95"] {
--bg-0: #c0c0c0;
--bg-1: #c0c0c0;
--bg-2: #ffffff;
--bg-3: #c0c0c0;
--bg-hover: #dfdfdf;
--bg-active: #000080;
--line: #808080;
--text: #000000;
--text-dim: #202020;
--text-faint: #606060;
--accent: #000080;
--accent-hover: #1084d0;
--accent-text: #ffffff;
--speaking: #008000;
--danger: #c00000;
--warn: #808000;
--link: #0000ee;
--font: Tahoma, "MS Sans Serif", "Microsoft Sans Serif", Geneva, Verdana, sans-serif;
--fs: 13px;
--rail: #008080;
--raised: inset -1px -1px #000, inset 1px 1px #fff, inset -2px -2px #808080, inset 2px 2px #dfdfdf;
--sunken: inset -1px -1px #fff, inset 1px 1px #808080, inset -2px -2px #dfdfdf, inset 2px 2px #000;
color-scheme: light;
}
:root[data-theme="win95"] body { -webkit-font-smoothing: none; }
:root[data-theme="win95"] ::selection { background: #000080; color: #fff; }
/* Buttons: raised, pressed in when active */
:root[data-theme="win95"] .btn,
:root[data-theme="win95"] .icon-btn,
:root[data-theme="win95"] .rail .tile,
:root[data-theme="win95"] .modes button,
:root[data-theme="win95"] .tabs button,
:root[data-theme="win95"] .tool,
:root[data-theme="win95"] .facade .play {
background: #c0c0c0;
color: #000;
border: 0;
box-shadow: var(--raised);
}
:root[data-theme="win95"] .btn:active:not(:disabled),
:root[data-theme="win95"] .icon-btn:active,
:root[data-theme="win95"] .rail .tile:active,
:root[data-theme="win95"] .modes button.active,
:root[data-theme="win95"] .tabs button.active,
:root[data-theme="win95"] .tool.on {
box-shadow: var(--sunken);
background: #c0c0c0;
color: #000;
}
:root[data-theme="win95"] .btn.primary { background: #c0c0c0; color: #000; outline: 1px solid #000; outline-offset: -1px; }
:root[data-theme="win95"] .btn.danger { color: #c00000; }
:root[data-theme="win95"] .icon-btn { color: #000; }
:root[data-theme="win95"] .icon-btn.on { color: #c00000; }
:root[data-theme="win95"] .btn:hover:not(:disabled),
:root[data-theme="win95"] .icon-btn:hover,
:root[data-theme="win95"] .tool:hover:not(:disabled) { background: #c0c0c0; color: #000; }
/* Text fields: sunken and white */
:root[data-theme="win95"] input:not([type=range]):not([type=checkbox]):not([type=color]),
:root[data-theme="win95"] textarea,
:root[data-theme="win95"] select,
:root[data-theme="win95"] .rich,
:root[data-theme="win95"] .composer .box,
:root[data-theme="win95"] .meter {
background: #fff;
color: #000;
border: 0;
box-shadow: var(--sunken);
}
:root[data-theme="win95"] .composer .box 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; }
/* Title bars */
:root[data-theme="win95"] .modal header,
:root[data-theme="win95"] .sidebar header,
:root[data-theme="win95"] .chat > header,
:root[data-theme="win95"] .panel > header {
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
border: 0;
}
:root[data-theme="win95"] .modal header { margin: 3px 3px 0; padding: 4px 4px 4px 8px; }
:root[data-theme="win95"] .modal header h2 { font-size: 13px; font-weight: 700; color: #fff; }
:root[data-theme="win95"] .modal header .icon-btn { width: 18px; height: 16px; }
:root[data-theme="win95"] .chat > header h1,
:root[data-theme="win95"] .panel > header h2,
:root[data-theme="win95"] .sidebar header strong,
:root[data-theme="win95"] .sidebar header .status,
:root[data-theme="win95"] .chat > header .topic { color: #fff; }
:root[data-theme="win95"] .chat > header .icon-btn,
:root[data-theme="win95"] .panel > header .icon-btn,
:root[data-theme="win95"] .sidebar header .icon-btn { width: 22px; height: 20px; }
/* Windows and panels */
:root[data-theme="win95"] .modal,
:root[data-theme="win95"] .menu,
:root[data-theme="win95"] .pop {
background: #c0c0c0;
border: 0;
box-shadow: var(--raised), 2px 2px 0 rgb(0 0 0 / 0.35);
}
:root[data-theme="win95"] .modal footer { background: #c0c0c0; border-top: 0; }
:root[data-theme="win95"] .menu button:hover:not(:disabled),
:root[data-theme="win95"] .menu button:focus-visible { background: #000080; color: #fff; }
:root[data-theme="win95"] .rail { background: var(--rail); }
:root[data-theme="win95"] .rail .tile.active::before { background: #fff; }
:root[data-theme="win95"] .rail .add,
:root[data-theme="win95"] .rail .ghost { color: #000; }
:root[data-theme="win95"] .sidebar,
:root[data-theme="win95"] .panel { border-left: 0; box-shadow: inset 1px 0 #fff; }
:root[data-theme="win95"] .sidebar .scroll,
:root[data-theme="win95"] .chat .list,
:root[data-theme="win95"] .members,
:root[data-theme="win95"] .panel .body {
background: #fff;
box-shadow: var(--sunken);
margin: 2px;
}
:root[data-theme="win95"] .me,
:root[data-theme="win95"] .elsewhere { background: #c0c0c0; box-shadow: var(--raised); }
:root[data-theme="win95"] .chat { background: #c0c0c0; }
:root[data-theme="win95"] .row.channel.selected,
:root[data-theme="win95"] .dm.viewing { background: #000080; color: #fff; }
:root[data-theme="win95"] .row.channel.selected .label { color: #fff; }
:root[data-theme="win95"] .badge,
:root[data-theme="win95"] .unread { background: #c00000; }
:root[data-theme="win95"] .card { background: #fff; box-shadow: var(--sunken); border-top: 0; border-right: 0; border-bottom: 0; }
:root[data-theme="win95"] .stage { background: #c0c0c0; }
:root[data-theme="win95"] .stage .tile { background: #c0c0c0; border: 0; box-shadow: var(--raised); }
:root[data-theme="win95"] .stage .tile.talking { box-shadow: var(--raised), inset 0 0 0 3px #008000; }
/* Classic scroll bars */
:root[data-theme="win95"] ::-webkit-scrollbar { width: 16px; height: 16px; }
:root[data-theme="win95"] ::-webkit-scrollbar-track {
background-color: #dfdfdf;
background-image: repeating-conic-gradient(#c0c0c0 0% 25%, #fff 0% 50%);
background-size: 2px 2px;
}
:root[data-theme="win95"] ::-webkit-scrollbar-thumb {
background: #c0c0c0;
border: 0;
box-shadow: var(--raised);
}
:root[data-theme="win95"] ::-webkit-scrollbar-button:single-button {
display: block;
height: 16px;
width: 16px;
background: #c0c0c0 no-repeat center / 8px 8px;
box-shadow: var(--raised);
}
:root[data-theme="win95"] ::-webkit-scrollbar-button:single-button:vertical:decrement {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath d='M4 2L7 6H1z'/%3E%3C/svg%3E");
}
:root[data-theme="win95"] ::-webkit-scrollbar-button:single-button:vertical:increment {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath d='M1 2h6L4 6z'/%3E%3C/svg%3E");
}
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { store, THEMES, type ThemeId } from '../lib/settings.svelte.ts';
function pick(id: ThemeId) {
store.settings.theme = id;
store.saveSettings();
}
</script>
<h3>Color scheme</h3>
<div class="themes" role="radiogroup" aria-label="Color scheme">
{#each THEMES as t (t.id)}
<button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id}
onclick={() => pick(t.id)} aria-label={t.name}>
<!-- A miniature of the app in that theme -->
<span class="mini" data-theme-preview={t.id} aria-hidden="true">
<span class="m-rail"><span></span><span></span></span>
<span class="m-side"><i></i><i class="sel"></i><i></i></span>
<span class="m-chat">
<span class="m-head"></span>
<i class="a"></i><i></i><i class="short"></i>
<span class="m-box"></span>
</span>
</span>
<span class="name">{t.name}</span>
<span class="note">{t.note}</span>
</button>
{/each}
</div>
<style>
h3 { margin: 8px 0 0; font-size: 14px; }
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
.themes > button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.name { font-weight: 600; margin-top: 6px; }
.note { font-size: 12px; color: var(--text-dim); }
.mini { display: flex; height: 84px; overflow: hidden; border: 1px solid var(--line); font-family: var(--font); }
.m-rail { width: 16%; background: var(--rail, var(--bg-0)); display: flex; flex-direction: column; align-items: center; gap: 4px; padding-top: 5px; }
.m-rail span { width: 60%; aspect-ratio: 1; background: var(--accent); }
.m-rail span + span { background: var(--bg-3); }
.m-side { width: 30%; background: var(--bg-1); display: flex; flex-direction: column; gap: 5px; padding: 8px 5px; }
.m-side i { height: 5px; background: var(--text-faint); width: 70%; }
.m-side i.sel { background: var(--accent); width: 85%; }
.m-chat { flex: 1; background: var(--bg-2); display: flex; flex-direction: column; gap: 5px; padding: 0 6px 6px; }
.m-head { height: 10px; margin: 0 -6px 2px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
[data-theme-preview="win95"] .m-head { background: linear-gradient(90deg, #000080, #1084d0); }
.m-chat i { height: 4px; background: var(--text-dim); width: 80%; }
.m-chat i.a { background: var(--accent); width: 35%; }
.m-chat i.short { width: 55%; }
.m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); }
</style>
+438
View File
@@ -0,0 +1,438 @@
<script lang="ts">
import Modal from './Modal.svelte';
import RichEditor from './RichEditor.svelte';
import { session } from '../lib/session.svelte.ts';
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
| { mode: 'edit'; id: number; tab?: 'general' | 'permissions' | 'groups'; onclose: () => void; parent?: undefined };
let props: Props = $props();
// svelte-ignore state_referenced_locally
const { mode, onclose } = props;
// svelte-ignore state_referenced_locally
const channelId = props.mode === 'edit' ? props.id : null;
// svelte-ignore state_referenced_locally
const parentId = props.mode === 'create' ? props.parent : null;
const client = session.client;
const channel = $derived(channelId != null ? session.channel(channelId) : undefined);
const parent = $derived(session.channel(parentId ?? channel?.parent ?? 0));
const isRoot = channelId === 0;
// svelte-ignore state_referenced_locally
let tab = $state<'general' | 'permissions' | 'groups'>(props.tab ?? 'general');
let error = $state('');
// ─── General ────────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? '');
let description = $state(initial?.description ?? '');
let position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false);
const canTemp = $derived(session.can(parentId ?? 0, PERM.MakeTempChannel) !== false);
const canPermanent = $derived(session.can(parentId ?? 0, PERM.MakeChannel) !== false);
$effect(() => { if (mode === 'create' && !canPermanent && canTemp) temporary = true; });
// Long descriptions arrive later; take them into the editor once they do
const descPending = $derived(!!channel?.descriptionHash && !channel.description);
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId);
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; }
});
// ─── ACL ────────────────────────────────────────────────────────────────────
let acl = $state<ChannelAcl | null>(null);
let aclDirty = $state(false);
let selectedRule = $state(-1);
let selectedGroup = $state('');
let names = $state<Record<number, string>>({});
const offs: (() => void)[] = [];
if (client && channelId != null) {
offs.push(client.on('acl', a => {
if (a.channelId !== channelId || acl) return;
acl = a;
selectedRule = a.acls.findIndex(r => !r.inherited);
selectedGroup = a.groups[0]?.name ?? '';
}));
offs.push(client.on('userNames', m => { names = Object.fromEntries(m); }));
names = Object.fromEntries(client.registeredNames);
client.queryAcl(channelId);
}
$effect(() => () => offs.forEach(off => off()));
const userName = (id: number) => names[id] ?? `user #${id}`;
const touch = () => { aclDirty = true; };
// Registered user id for a name, asked from the server
function resolveName(raw: string): Promise<number | null> {
const wanted = raw.trim().toLowerCase();
const known = Object.entries(names).find(([, n]) => n.toLowerCase() === wanted);
if (known) return Promise.resolve(Number(known[0]));
if (!client || !wanted) return Promise.resolve(null);
return new Promise(resolve => {
const timer = setTimeout(() => { off(); resolve(null); }, 3000);
const off = client.on('userNames', m => {
for (const [id, n] of m) if (n.toLowerCase() === wanted) { clearTimeout(timer); off(); resolve(id); return; }
});
client.queryUsers({ names: [raw.trim()] });
});
}
const PERMS: { bit: number; name: string; rootOnly?: boolean }[] = [
{ bit: PERM.Write, name: 'Write ACL (full control)' },
{ bit: PERM.Traverse, name: 'Traverse' },
{ bit: PERM.Enter, name: 'Enter' },
{ bit: PERM.Speak, name: 'Speak' },
{ bit: PERM.Whisper, name: 'Whisper' },
{ bit: PERM.TextMessage, name: 'Text message' },
{ bit: PERM.Listen, name: 'Listen' },
{ bit: PERM.MuteDeafen, name: 'Mute and deafen' },
{ bit: PERM.Move, name: 'Move users' },
{ bit: PERM.MakeChannel, name: 'Make channel' },
{ bit: PERM.MakeTempChannel, name: 'Make temporary channel' },
{ bit: PERM.LinkChannel, name: 'Link channel' },
{ bit: PERM.Kick, name: 'Kick users', rootOnly: true },
{ bit: PERM.Ban, name: 'Ban users', rootOnly: true },
{ bit: PERM.Register, name: 'Register users', rootOnly: true },
{ bit: PERM.SelfRegister, name: 'Register self', rootOnly: true },
{ bit: PERM.ResetUserContent, name: 'Reset user content', rootOnly: true }
];
const permList = $derived(PERMS.filter(p => !p.rootOnly || isRoot));
const BUILTIN_GROUPS = ['all', 'auth', 'in', 'out', 'sub', 'admin'];
const groupSuggestions = $derived([...new Set([...BUILTIN_GROUPS, ...(acl?.groups.map(g => g.name) ?? [])])]);
// $<certhash> groups match one certificate; name them after the online user when we can
function groupLabel(g: string | null): string {
if (g?.startsWith('$')) {
const u = session.allUsers().find(x => x.hash === g.slice(1));
return u ? `Certificate of ${u.name}` : `Certificate ${g.slice(1, 9)}...`;
}
return `@${g}`;
}
const ruleLabel = (r: AclEntry) => r.userId != null ? userName(r.userId) : groupLabel(r.group);
const rule = $derived(acl && selectedRule >= 0 ? acl.acls[selectedRule] : null);
let targetText = $state('');
let targetKind = $state<'group' | 'user'>('group');
$effect(() => {
const r = rule;
if (!r) return;
targetKind = r.userId != null ? 'user' : 'group';
targetText = r.userId != null ? userName(r.userId) : r.group ?? 'all';
});
function addRule() {
if (!acl) return;
acl.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0 });
selectedRule = acl.acls.length - 1;
touch();
}
function removeRule() {
if (!acl || !rule || rule.inherited) return;
acl.acls.splice(selectedRule, 1);
selectedRule = Math.min(selectedRule, acl.acls.length - 1);
touch();
}
// Rules are evaluated in order; only the channel's own rules can move, and not above inherited ones
function moveRule(dir: -1 | 1) {
if (!acl || !rule || rule.inherited) return;
const to = selectedRule + dir;
if (to < 0 || to >= acl.acls.length || acl.acls[to].inherited) return;
[acl.acls[selectedRule], acl.acls[to]] = [acl.acls[to], acl.acls[selectedRule]];
selectedRule = to;
touch();
}
async function applyTarget() {
if (!rule || rule.inherited) return;
error = '';
if (targetKind === 'group') {
rule.userId = null;
rule.group = targetText.trim().replace(/^@/, '') || 'all';
} else {
const id = await resolveName(targetText);
if (id == null) { error = `"${targetText}" is not a registered user on this server.`; return; }
rule.userId = id;
rule.group = null;
}
touch();
}
function setBit(kind: 'grant' | 'deny', bit: number, on: boolean) {
if (!rule || rule.inherited) return;
const other = kind === 'grant' ? 'deny' : 'grant';
rule[kind] = on ? rule[kind] | bit : rule[kind] & ~bit;
if (on) rule[other] &= ~bit; // allow and deny exclude each other
touch();
}
// ─── Groups ─────────────────────────────────────────────────────────────────
const group = $derived(acl?.groups.find(g => g.name === selectedGroup) ?? null);
let newGroup = $state('');
let newMember = $state('');
function addGroup() {
const n = newGroup.trim().replace(/^@/, '');
if (!acl || !n || acl.groups.some(g => g.name === n)) return;
acl.groups.push({ name: n, inherited: false, inherit: true, inheritable: true, add: [], remove: [], inheritedMembers: [] });
selectedGroup = n;
newGroup = '';
touch();
}
function deleteGroup() {
if (!acl || !group || group.inherited) return;
acl.groups = acl.groups.filter(g => g !== group);
selectedGroup = acl.groups[0]?.name ?? '';
touch();
}
async function addMember() {
if (!group) return;
error = '';
const id = await resolveName(newMember);
if (id == null) { error = `"${newMember}" is not a registered user on this server.`; return; }
if (!group.add.includes(id)) group.add.push(id);
group.remove = group.remove.filter(x => x !== id);
newMember = '';
touch();
}
function toggleInheritedMember(g: AclGroup, id: number) {
g.remove = g.remove.includes(id) ? g.remove.filter(x => x !== id) : [...g.remove, id];
touch();
}
// ─── Save ───────────────────────────────────────────────────────────────────
function save() {
if (!client) return;
error = '';
if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; }
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers });
sounds.play('channelCreated');
return onclose();
}
const ch = channel;
if (!ch || channelId == null) return onclose();
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim();
if (descLoaded && description !== ch.description) fields.description = description;
if (position !== ch.position) fields.position = Number(position) || 0;
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
if (Object.keys(fields).length) client.updateChannel(channelId, fields);
if (acl && aclDirty) client.saveAcl($state.snapshot(acl) as ChannelAcl);
if (Object.keys(fields).length || aclDirty) sounds.play('channelEdited');
onclose();
}
const title = $derived(mode === 'create' ? `New channel in ${parent?.name || 'Root'}` : `Channel: ${channel?.name || 'Root'}`);
</script>
<Modal {title} {onclose} width={760}>
{#if mode === 'edit'}
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'general'} class:active={tab === 'general'} onclick={() => (tab = 'general')}>General</button>
<button role="tab" aria-selected={tab === 'permissions'} class:active={tab === 'permissions'} onclick={() => (tab = 'permissions')}>Permissions</button>
<button role="tab" aria-selected={tab === 'groups'} class:active={tab === 'groups'} onclick={() => (tab = 'groups')}>Groups</button>
</div>
{/if}
{#if tab === 'general'}
<label for="ch-name">Name</label>
<input id="ch-name" bind:value={name} disabled={isRoot} placeholder="Channel name" />
<label for="ch-desc">Description</label>
{#if descLoaded}
<RichEditor value={description} limit={session.htmlLimit} placeholder="What is this channel for?" onchange={h => (description = h)} />
{:else}
<p class="dim">Loading description...</p>
{/if}
<div class="row3">
<div>
<label for="ch-pos">Position</label>
<input id="ch-pos" type="number" bind:value={position} />
</div>
<div>
<label for="ch-max">User limit</label>
<input id="ch-max" type="number" min="0" bind:value={maxUsers} />
</div>
{#if mode === 'create'}
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if}
</div>
<p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
{:else if !acl}
<p class="dim">Loading permissions...</p>
{:else if tab === 'permissions'}
<label class="check top"><input type="checkbox" bind:checked={acl.inheritAcls} onchange={touch} /> Inherit rules from the parent channel</label>
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Rules">
{#each acl.acls as r, i (i)}
<li>
<button role="option" aria-selected={i === selectedRule} class:sel={i === selectedRule} class:inh={r.inherited} onclick={() => (selectedRule = i)}>
<span>{ruleLabel(r)}</span>
{#if r.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No rules yet</li>
{/each}
</ul>
<div class="listbtns">
<button class="btn" onclick={addRule}>Add</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={removeRule}>Remove</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(-1)} aria-label="Move up">Up</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(1)} aria-label="Move down">Down</button>
</div>
</div>
<div class="detail">
{#if rule}
{#if rule.inherited}<p class="note">Inherited from a parent channel. Edit it there, or turn off inheriting above.</p>{/if}
<fieldset disabled={rule.inherited}>
<div class="target">
<select bind:value={targetKind} aria-label="Target type">
<option value="group">Group</option>
<option value="user">User</option>
</select>
<input bind:value={targetText} list="ch-groups" aria-label="Target" onchange={applyTarget}
placeholder={targetKind === 'group' ? 'all, auth, in, admin...' : 'Registered user name'} />
<datalist id="ch-groups">{#each groupSuggestions as g (g)}<option value={g}></option>{/each}</datalist>
</div>
<div class="applies">
<label class="check"><input type="checkbox" bind:checked={rule.applyHere} onchange={touch} /> Applies to this channel</label>
<label class="check"><input type="checkbox" bind:checked={rule.applySubs} onchange={touch} /> Applies to subchannels</label>
</div>
<table class="perms">
<thead><tr><th>Permission</th><th>Allow</th><th>Deny</th></tr></thead>
<tbody>
{#each permList as p (p.bit)}
<tr>
<td>{p.name}</td>
<td><input type="checkbox" aria-label="Allow {p.name}" checked={(rule.grant & p.bit) !== 0} onchange={e => setBit('grant', p.bit, e.currentTarget.checked)} /></td>
<td><input type="checkbox" aria-label="Deny {p.name}" checked={(rule.deny & p.bit) !== 0} onchange={e => setBit('deny', p.bit, e.currentTarget.checked)} /></td>
</tr>
{/each}
</tbody>
</table>
</fieldset>
{:else}
<p class="dim">Select a rule, or add one.</p>
{/if}
</div>
</div>
{:else}
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Groups">
{#each acl.groups as g (g.name)}
<li>
<button role="option" aria-selected={g.name === selectedGroup} class:sel={g.name === selectedGroup} onclick={() => (selectedGroup = g.name)}>
<span>@{g.name}</span>
{#if g.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No groups yet</li>
{/each}
</ul>
<div class="listbtns">
<input bind:value={newGroup} placeholder="New group" aria-label="New group name" onkeydown={e => { if (e.key === 'Enter') addGroup(); }} />
<button class="btn" onclick={addGroup} disabled={!newGroup.trim()}>Add</button>
<button class="btn" disabled={!group || group.inherited} onclick={deleteGroup}>Delete</button>
</div>
</div>
<div class="detail">
{#if group}
<label class="check"><input type="checkbox" bind:checked={group.inherit} onchange={touch} /> Include members of the parent's @{group.name}</label>
<label class="check"><input type="checkbox" bind:checked={group.inheritable} onchange={touch} /> Subchannels can use this group</label>
<h4>Members added here</h4>
<ul class="members">
{#each group.add as id (id)}
<li><span>{userName(id)}</span><button class="link danger" onclick={() => { group.add = group.add.filter(x => x !== id); touch(); }}>Remove</button></li>
{:else}
<li class="dim small">Nobody yet</li>
{/each}
</ul>
<div class="target">
<input bind:value={newMember} placeholder="Registered user name" aria-label="Member name" onkeydown={e => { if (e.key === 'Enter') addMember(); }} />
<button class="btn" onclick={addMember} disabled={!newMember.trim()}>Add member</button>
</div>
{#if group.inheritedMembers.length}
<h4>Inherited members</h4>
<ul class="members">
{#each group.inheritedMembers as id (id)}
{@const excluded = group.remove.includes(id)}
<li><span class:struck={excluded}>{userName(id)}</span>
<button class="link" onclick={() => toggleInheritedMember(group, id)}>{excluded ? 'Include again' : 'Exclude here'}</button></li>
{/each}
</ul>
{/if}
{:else}
<p class="dim">Select a group, or add one.</p>
{/if}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" onclick={save}>{mode === 'create' ? 'Create channel' : 'Save'}</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.row3 { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.row3 > div { width: 140px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin: 10px 0 0; }
.check input { width: auto; }
.check.top { margin-top: 12px; }
.split { display: grid; grid-template-columns: 220px 1fr; gap: 14px; margin-top: 12px; }
@media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
.list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); background: var(--bg-0); max-height: 340px; overflow-y: auto; }
.list button { display: flex; justify-content: space-between; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; text-align: left; font-size: 14px; }
.list button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list { overflow-x: hidden; }
.list button:hover { background: var(--bg-hover); }
.list button.sel { background: var(--accent); color: var(--accent-text); }
.list button.inh:not(.sel) { color: var(--text-dim); }
.list small { font-size: 11px; opacity: 0.8; }
.pad { padding: 8px 10px; }
.listbtns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.listbtns .btn { padding: 4px 10px; font-size: 13px; }
.listbtns input { flex: 1 1 100%; }
.detail { min-width: 0; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
fieldset:disabled { opacity: 0.6; }
.target { display: flex; gap: 6px; }
.target select { width: 100px; flex: none; }
.applies { display: flex; gap: 16px; flex-wrap: wrap; }
.perms { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
.perms th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.perms td { padding: 3px 6px; border-bottom: 1px solid var(--line); }
.perms th:not(:first-child), .perms td:not(:first-child) { text-align: center; width: 60px; }
.perms input { width: auto; }
h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.members { list-style: none; margin: 0 0 8px; padding: 0; }
.members li { display: flex; justify-content: space-between; padding: 3px 0; font-size: 14px; }
.struck { text-decoration: line-through; color: var(--text-faint); }
.link { color: var(--link); font-size: 13px; }
.link.danger { color: var(--danger); }
.note { margin: 0 0 8px; font-size: 13px; color: var(--warn); }
.dim { color: var(--text-dim); }
.small { font-size: 12px; }
.error { color: var(--danger); margin: 10px 0 0; }
</style>
+43 -3
View File
@@ -8,6 +8,8 @@
import { channelMenu, userMenu } from '../lib/actions.ts'; import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { drag } from '../lib/drag.svelte.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
import type { Channel } from '../core/client.ts'; import type { Channel } from '../core/client.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -30,6 +32,32 @@
}; };
} }
// Drop a person to move them here, or a channel to make it a subchannel of this one
let over = $state(false);
const canDrop = $derived.by(() => {
const d = drag.item;
if (!d) return false;
if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id;
return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id);
});
function onDrop(e: DragEvent) {
e.preventDefault();
over = false;
// Check before clearing: canDrop is derived from the dragged item
const d = drag.item;
const ok = canDrop;
drag.item = null;
if (!d || !ok) return;
if (d.kind === 'user') {
if (d.id === session.self?.session) session.joinChannel(channel.id);
else session.moveUser(d.id, channel.id);
} else {
session.client?.updateChannel(d.id, { parent: channel.id });
sounds.play('channelMoved');
}
}
const channelClicks = clickOrDouble( const channelClicks = clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; }, () => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); } () => { session.joinChannel(channel.id); onnavigate(); }
@@ -37,8 +65,11 @@
</script> </script>
<li> <li>
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px" <div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"> oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
ondragleave={() => (over = false)}
ondrop={onDrop}>
{#if children.length || users.length} {#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)} <button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}> aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
@@ -47,7 +78,11 @@
{:else} {:else}
<span class="caret"></span> <span class="caret"></span>
{/if} {/if}
<button class="name" title="Click for details, double-click to join" {...channelClicks}> <!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
<button class="name" title="Click for details, double-click to join" {...channelClicks}
draggable={channel.id !== 0}
ondragstart={e => { drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragend={() => { drag.item = null; over = false; }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if} {#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span> <span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if} {#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
@@ -65,6 +100,9 @@
)} )}
<li> <li>
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px" <button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
draggable="true"
ondragstart={e => { drag.item = { kind: 'user', id: u.session }; e.dataTransfer?.setData('text/plain', u.name); }}
ondragend={() => (drag.item = null)}
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks} title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
oncontextmenu={e => menu.show(e, userMenu(u))}> oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} /> <Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
@@ -98,6 +136,8 @@
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); } .row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); } .channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); } .channel.selected { background: var(--bg-active); color: var(--text); }
.channel.over { background: var(--accent); color: var(--accent-text); }
.channel.over .label { color: var(--accent-text); }
.channel.here .label { color: var(--text); font-weight: 600; } .channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); } .channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); } .caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
+28 -1
View File
@@ -3,6 +3,7 @@
import ChatLog from './ChatLog.svelte'; import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte'; import Composer from './Composer.svelte';
import VoiceStage from './VoiceStage.svelte'; import VoiceStage from './VoiceStage.svelte';
import { ui } from '../lib/ui.svelte.ts';
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
// showStage: voice tiles above the chat when the window is too narrow for the member list // showStage: voice tiles above the chat when the window is too narrow for the member list
@@ -28,6 +29,24 @@
{#if showStage && session.status === 'connected'}<VoiceStage />{/if} {#if showStage && session.status === 'connected'}<VoiceStage />{/if}
{#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
<div class="ended" class:bad={d.kind === 'kicked' || d.kind === 'banned'}>
<h2>
{#if d.kind === 'kicked'}You were kicked{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'banned'}You were banned{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'rejected'}The server refused the connection
{:else}Connection lost{/if}
</h2>
{#if d.reason}<p class="reason">{d.reason}</p>{/if}
<p class="where">{session.server?.label}</p>
<div class="row">
<button class="btn primary" onclick={() => session.reconnect()}>Reconnect</button>
</div>
</div>
{/if}
<ChatLog key={session.view}> <ChatLog key={session.view}>
{#snippet empty()} {#snippet empty()}
<div class="empty"> <div class="empty">
@@ -38,7 +57,8 @@
<h2>Connecting...</h2> <h2>Connecting...</h2>
{:else} {:else}
<h2>mumh5</h2> <h2>mumh5</h2>
<p>Choose a server to get started.</p> <p>Choose a server to get started, or find one:</p>
<button class="btn browse" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -59,4 +79,11 @@
.empty { padding: 32px 16px 8px; } .empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; } .empty h2 { margin: 0 0 4px; font-size: 24px; }
.empty p { margin: 0; color: var(--text-dim); } .empty p { margin: 0; color: var(--text-dim); }
.browse { margin-top: 12px; }
.ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
.ended.bad { border-left-color: var(--danger); }
.ended h2 { margin: 0; font-size: 20px; }
.ended .reason { margin: 8px 0 0; color: var(--text); font-size: 15px; overflow-wrap: anywhere; }
.ended .where { margin-top: 4px; font-size: 13px; color: var(--text-faint); }
.ended .row { margin-top: 14px; }
</style> </style>
+110 -21
View File
@@ -1,48 +1,112 @@
<script lang="ts"> <script lang="ts">
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import FormatToolbar from './FormatToolbar.svelte';
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { acceptAttr } from '../lib/upload.ts'; import { acceptAttr } from '../lib/upload.ts';
import { toMumbleHtml } from '../lib/html.ts';
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>") // target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props(); let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
let text = $state(''); let input: HTMLDivElement;
let input: HTMLTextAreaElement;
let picker: HTMLInputElement; let picker: HTMLInputElement;
let imagePicker: HTMLInputElement;
let empty = $state(true);
let source = $state(false);
let sourceText = $state('');
// The formatting toolbar is opt-in and remembered
let formatting = $state(loadFormatting());
const disabled = $derived(session.status !== 'connected'); const disabled = $derived(session.status !== 'connected');
const uploads = $derived(session.uploads.filter(u => u.key === target)); const uploads = $derived(session.uploads.filter(u => u.key === target));
function loadFormatting(): boolean {
try { return localStorage.getItem('mumh5.formatBar') === '1'; } catch { return false; }
}
function toggleFormatting() {
formatting = !formatting;
try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ }
if (!formatting && source) toggleSource();
input.focus();
}
// Know the host's allowed types before the picker opens // Know the host's allowed types before the picker opens
$effect(() => { $effect(() => {
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {}); if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
}); });
const sync = () => { empty = !input.textContent?.trim() && !input.querySelector('img'); };
function exec(cmd: string, arg?: string) {
input.focus();
document.execCommand(cmd, false, arg);
sync();
}
function submit() { function submit() {
if (session.sendText(text, target)) text = ''; const html = source ? sourceText : input.innerHTML;
// Plain text goes out the usual way; anything formatted as sanitized XHTML
const formatted = source || !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img');
const ok = formatted
? session.sendHtml(toMumbleHtml(html, { linkify: true }), target)
: session.sendText(input.innerText, target);
if (!ok) return;
input.innerHTML = '';
sourceText = '';
if (source) source = false;
sync();
} }
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault(); e.preventDefault();
submit(); if (e.shiftKey) exec('insertLineBreak');
else submit();
return;
}
if (e.ctrlKey || e.metaKey) {
const cmd = ({ b: 'bold', i: 'italic', u: 'underline' } as Record<string, string>)[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); exec(cmd); }
} }
} }
function onpaste(e: ClipboardEvent) { function onpaste(e: ClipboardEvent) {
const files = [...(e.clipboardData?.files ?? [])]; const files = [...(e.clipboardData?.files ?? [])];
if (files.length) {
e.preventDefault(); e.preventDefault();
session.uploadFiles(files, target); if (files.length) return session.uploadFiles(files, target);
} const html = e.clipboardData?.getData('text/html');
if (html && formatting) exec('insertHTML', toMumbleHtml(html));
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
} }
// Grow with content up to a limit // window.prompt does not exist in Electron; the app's dialog, then restore the selection
$effect(() => { function link() {
void text; const sel = window.getSelection();
input.style.height = 'auto'; const range = sel && sel.rangeCount && input.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
input.style.height = Math.min(input.scrollHeight, 200) + 'px'; ui.prompt = {
}); title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
onsubmit: raw => {
const url = raw.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
input.focus();
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
if (range && !range.collapsed) exec('createLink', url);
else exec('insertHTML', `<a href="${url.replace(/"/g, '&quot;')}">${url.replace(/</g, '&lt;')}</a>`);
}
};
}
function toggleSource() {
if (!source) {
sourceText = toMumbleHtml(input.innerHTML);
source = true;
} else {
input.innerHTML = toMumbleHtml(sourceText);
source = false;
sync();
}
}
$effect(() => { $effect(() => {
void target; void target;
@@ -62,21 +126,46 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
<div class="frame">
{#if formatting}
<FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} {source} ontogglesource={toggleSource} />
{/if}
<div class="box"> <div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button> <button class="icon-btn" title={store.settings.uploadHost ? 'Upload a file' : 'Send an image'} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden <input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'} accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} /> onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste} <input bind:this={imagePicker} type="file" accept="image/*" hidden
aria-label="Message"></textarea> onchange={() => { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} />
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button> <div class="input" class:blank={empty} class:hidden={source} bind:this={input} contenteditable={!disabled}
role="textbox" aria-multiline="true" aria-label="Message" tabindex="0" data-placeholder={placeholder}
oninput={sync} {onkeydown} {onpaste}></div>
{#if source}
<textarea class="input src" bind:value={sourceText} aria-label="HTML source" spellcheck="false"
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }}></textarea>
{/if}
<button class="icon-btn fmt" class:on={formatting} title="Text formatting" aria-label="Text formatting" aria-pressed={formatting}
{disabled} onmousedown={e => e.preventDefault()} onclick={toggleFormatting}>Aa</button>
<button class="icon-btn send" title="Send" disabled={disabled || (empty && !(source && sourceText.trim()))} onclick={submit}><Icon name="send" size={18} /></button>
</div>
</div> </div>
</div> </div>
<style> <style>
.composer { flex: none; padding: 0 16px 16px; } /* Same height as the user panel at the bottom of the sidebar; grows for longer messages */
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; } .composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 6px 16px; background: var(--bg-0); }
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; } .frame { background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: calc(var(--footer-h) - 12px); padding: 2px 4px; }
.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.hidden { display: 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(ul), .input :global(ol) { margin: 0; padding-left: 20px; }
.input[contenteditable='false'] { color: var(--text-faint); }
.src { border: 0; resize: none; background: transparent; font: 12px var(--mono); min-height: 60px; }
.fmt { font-weight: 700; font-size: 13px; }
.fmt.on { color: var(--accent-hover); }
.send:not(:disabled) { color: var(--accent-hover); } .send:not(:disabled) { color: var(--accent-hover); }
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; } .uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; } .uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
+9 -5
View File
@@ -5,14 +5,18 @@
import { identities } from '../lib/identities.svelte.ts'; import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts'; import { makeServerIcon } from '../lib/upload.ts';
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props(); // prefill: values for a new server, e.g. picked from the public list
let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const initial = server; const initial = server;
let label = $state(initial?.label ?? ''); // svelte-ignore state_referenced_locally
let host = $state(initial?.host ?? ''); const pre = prefill ?? {};
let port = $state(initial?.port ?? 64738); let label = $state(initial?.label ?? pre.label ?? '');
let username = $state(initial?.username ?? ''); let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738);
// New servers start with the name you used last
let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? '');
let password = $state(initial?.password ?? ''); let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? ''); let identityId = $state(initial?.identityId ?? '');
let icon = $state(initial?.icon ?? ''); let icon = $state(initial?.icon ?? '');
+7 -3
View File
@@ -16,7 +16,7 @@
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)), x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4)) y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
}; };
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus(); if (!m.keepFocus) el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
}); });
function onkeydown(e: KeyboardEvent) { function onkeydown(e: KeyboardEvent) {
@@ -39,7 +39,9 @@
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div> <div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px"> <div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#each menu.open.items as item, i (i)} {#each menu.open.items as item, i (i)}
{#if item.separator} {#if item.header}
<div class="head" role="presentation">{item.label}</div>
{:else if item.separator}
<div class="sep" role="separator"></div> <div class="sep" role="separator"></div>
{:else if item.slider} {:else if item.slider}
{@const sl = item.slider} {@const sl = item.slider}
@@ -51,7 +53,7 @@
</div> </div>
{:else} {:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled} <button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}> onmousedown={e => e.preventDefault()} onclick={() => { menu.close(); item.action?.(); }}>
<span class="label">{item.label}</span> <span class="label">{item.label}</span>
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if} {#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
</button> </button>
@@ -74,6 +76,8 @@
.box { width: 14px; height: 14px; border: 2px solid currentColor; } .box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; } .box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); } .sep { height: 1px; margin: 4px 6px; background: var(--line); }
.head { padding: 4px 10px 6px; margin-bottom: 4px; font-size: 12px; font-weight: 700; color: var(--text-dim); border-bottom: 1px solid var(--line);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slider { padding: 6px 10px 8px; } .slider { padding: 6px 10px 8px; }
.slider-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); } .slider-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
.slider input { width: 100%; padding: 0; border: 0; background: transparent; accent-color: var(--accent); margin-top: 4px; } .slider input { width: 100%; padding: 0; border: 0; background: transparent; accent-color: var(--accent); margin-top: 4px; }
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Icon from './Icon.svelte';
// Formatting controls shared by the description editor and the message input.
// Buttons keep the text selection (mousedown is suppressed) and act through the callbacks.
let { exec, onlink, onimage, source = false, ontogglesource }:
{ exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource: () => void } = $props();
let color = $state('#e8b23c');
const keep = (e: MouseEvent) => e.preventDefault();
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={keep} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={keep} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={keep} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={keep} onclick={onlink}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={keep} onclick={onimage}><Icon name="image" size={16} /></button>
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={keep} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button>
</div>
<style>
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
</style>
+11 -2
View File
@@ -3,11 +3,20 @@
import YouTubeEmbed from './YouTubeEmbed.svelte'; import YouTubeEmbed from './YouTubeEmbed.svelte';
import LinkPreview from './LinkPreview.svelte'; import LinkPreview from './LinkPreview.svelte';
import { store } from '../lib/settings.svelte.ts'; import { store } from '../lib/settings.svelte.ts';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import type { ChatEntry } from '../lib/session.svelte.ts'; import type { ChatEntry } from '../lib/session.svelte.ts';
let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props(); let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props();
const time = $derived(new Date(entry.time)); const time = $derived(new Date(entry.time));
// Right-click on the author opens the user menu while they are still online
function authorMenu(e: MouseEvent) {
const u = entry.authorSession != null ? session.user(entry.authorSession) : undefined;
if (u) menu.show(e, userMenu(u));
}
const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); const hhmm = $derived(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
</script> </script>
@@ -22,13 +31,13 @@
{#if grouped} {#if grouped}
<span class="time hover">{hhmm}</span> <span class="time hover">{hhmm}</span>
{:else} {:else}
<Avatar name={entry.author} size={38} /> <span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>
{/if} {/if}
</div> </div>
<div class="content"> <div class="content">
{#if !grouped} {#if !grouped}
<header> <header>
<strong class:self={entry.self}>{entry.author}</strong> <strong class:self={entry.self} oncontextmenu={authorMenu}>{entry.author}</strong>
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time> <time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
</header> </header>
{/if} {/if}
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
let servers = $state<PublicServer[]>([]);
let loading = $state(true);
let error = $state('');
let search = $state('');
let continent = $state('');
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users');
let pings = $state<Record<string, PingResult | null>>({});
let alive = true;
// Ping queue; the generation drops answers from a previous list
let queue: PublicServer[] = [];
let running = 0;
let generation = 0;
onDestroy(() => { alive = false; });
const key = (s: PublicServer) => `${s.host}:${s.port}`;
const CONTINENTS: Record<string, string> = { eu: 'Europe', na: 'North America', sa: 'South America', as: 'Asia', oc: 'Oceania', af: 'Africa', an: 'Antarctica' };
async function load() {
// A new list starts a new ping round; answers from the previous round are dropped
generation++;
queue = [];
running = 0;
loading = true;
error = '';
try {
servers = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
if (!native) error = 'The public list needs the desktop app.';
} catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
loading = false;
pingVisible();
}
load();
const shown = $derived.by(() => {
const q = search.trim().toLowerCase();
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)));
const p = (s: PublicServer) => pings[key(s)];
return list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9);
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1);
}).slice(0, 300);
});
// Ping what the filter shows, a few at a time; each answer fills in users and latency
function pingVisible() {
queue = servers.filter(s => !(key(s) in pings));
for (let i = running; i < 16; i++) next();
}
function next() {
const s = queue.shift();
if (!s || !alive || !native) return;
const round = generation;
running++;
pings[key(s)] = null;
native.pingServer(s.host, s.port).then(r => {
if (!alive || round !== generation) return;
pings[key(s)] = r;
running--;
next();
});
}
function add(s: PublicServer) {
ui.addServer = { host: s.host, port: s.port, label: s.name };
onclose();
}
function changeSource() {
ui.prompt = {
title: 'Public list address', label: 'URL', confirm: 'Use this list', initial: store.settings.publicListUrl,
text: 'The official list is publist.mumble.info. Any server returning the same XML format works.',
onsubmit: v => {
store.settings.publicListUrl = v.trim() || 'https://publist.mumble.info/v1/list?version=1.5.0';
store.saveSettings();
pings = {};
load();
}
};
}
</script>
<Modal title="Public servers" {onclose} width={900}>
<div class="bar">
<input bind:value={search} placeholder="Search by name, country or address" aria-label="Search servers" />
<select bind:value={continent} aria-label="Continent">
<option value="">All continents</option>
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
</select>
<select bind:value={sort} aria-label="Sort by">
<option value="users">Most people</option>
<option value="ping">Lowest ping</option>
<option value="name">Name</option>
<option value="country">Country</option>
</select>
</div>
{#if loading}
<p class="dim">Loading the public server list...</p>
{:else if error}
<p class="error">Could not load the list: {error}</p>
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
{:else}
<table>
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead>
<tbody>
{#each shown as s (key(s))}
{@const p = pings[key(s)]}
<tr>
<td class="name">
<span class="title" title={s.name}>{s.name}</span>
<span class="addr">{s.host}{s.port !== 64738 ? `:${s.port}` : ''}</span>
{#if s.url}<a class="site" href={s.url} target="_blank" rel="noopener noreferrer">website</a>{/if}
</td>
<td>{s.country}</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="act"><button class="btn" onclick={() => add(s)}>Add</button></td>
</tr>
{:else}
<tr><td colspan="5" class="dim">No servers match.</td></tr>
{/each}
</tbody>
</table>
<p class="dim small">{servers.length} servers listed{shown.length < servers.length ? `, ${shown.length} shown` : ''}. Pinging lets each server see your IP address, like in the desktop client.</p>
{/if}
{#snippet footer()}
<button class="link" onclick={changeSource}>List source: {(() => { try { return new URL(store.settings.publicListUrl).host; } catch { return store.settings.publicListUrl; } })()}</button>
<span class="grow"></span>
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
.bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.bar input { flex: 1 1 240px; }
.bar select { width: auto; flex: 0 0 auto; }
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); }
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:hover td { background: var(--bg-hover); }
.name { max-width: 360px; }
.title { display: block; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.addr { font-size: 12px; color: var(--text-faint); }
.site { font-size: 12px; margin-left: 8px; }
.num { text-align: right; white-space: nowrap; }
.good { color: var(--speaking); }
.bad { color: var(--warn); }
.act { width: 1%; }
.act .btn { padding: 4px 12px; font-size: 13px; }
.dim { color: var(--text-dim); }
.small { font-size: 12px; margin-top: 10px; }
.error { color: var(--danger); }
.row { display: flex; gap: 8px; }
.link { color: var(--link); font-size: 13px; }
.grow { flex: 1; }
@media (max-width: 640px) { th:nth-child(2), td:nth-child(2) { display: none; } }
</style>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
// the right of the window is resized from its left edge and vice versa. Width is remembered.
let { width = $bindable(), edge, min, max, initial, storageKey, label }:
{ width: number; edge: 'left' | 'right'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(min, Math.min(max(), w)));
try { localStorage.setItem(storageKey, String(width)); } catch { /* storage unavailable */ }
}
function start(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
document.body.style.userSelect = 'none';
const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : ev.clientX - box.left);
const up = () => {
resizing = false;
document.body.style.userSelect = '';
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function onkeydown(e: KeyboardEvent) {
const grow = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
const shrink = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
if (e.key === grow) setWidth(width + 16);
else if (e.key === shrink) setWidth(width - 16);
else return;
e.preventDefault();
}
</script>
<svelte:window onresize={() => setWidth(width)} />
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation="vertical" aria-label={label}
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
<style>
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
.resize.left { left: -4px; }
.resize.right { right: -4px; }
.resize:hover, .resize:focus-visible, .resize.active {
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
outline: none;
}
</style>
+2 -37
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Icon from './Icon.svelte'; import FormatToolbar from './FormatToolbar.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts'; import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts'; import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
@@ -17,7 +17,6 @@
let sourceText = $state(''); let sourceText = $state('');
let html = $state(''); let html = $state('');
let error = $state(''); let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length); const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit); const over = $derived(limit > 0 && size > limit);
@@ -96,34 +95,11 @@
if (cmd) { e.preventDefault(); exec(cmd); } if (cmd) { e.preventDefault(); exec(cmd); }
} }
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script> </script>
<div class="rich" class:over> <div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting"> <FormatToolbar {exec} onlink={link} onimage={() => picker.click()} {source} ontogglesource={toggleSource} />
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} /> <input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true" <div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div> aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
@@ -142,18 +118,7 @@
.rich { border: 1px solid var(--line); background: var(--bg-0); } .rich { border: 1px solid var(--line); background: var(--bg-0); }
.rich:focus-within { border-color: var(--accent); } .rich:focus-within { border-color: var(--accent); }
.rich.over { border-color: var(--danger); } .rich.over { border-color: var(--danger); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; } .grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; } .area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
.area.hidden { display: none; } .area.hidden { display: none; }
.area :global(p) { margin: 0 0 6px; } .area :global(p) { margin: 0 0 6px; }
+3 -52
View File
@@ -2,6 +2,7 @@
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import Avatar from './Avatar.svelte'; import Avatar from './Avatar.svelte';
import Members from './Members.svelte'; import Members from './Members.svelte';
import ResizeHandle from './ResizeHandle.svelte';
import ChatLog from './ChatLog.svelte'; import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte'; import Composer from './Composer.svelte';
import { session } from '../lib/session.svelte.ts'; import { session } from '../lib/session.svelte.ts';
@@ -33,37 +34,7 @@
const MIN_W = 240, DEFAULT_W = 300; const MIN_W = 240, DEFAULT_W = 300;
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420)); const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W)); let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(MIN_W, Math.min(maxW(), w)));
try { localStorage.setItem('mumh5.panelWidth', String(width)); } catch { /* storage unavailable */ }
}
function startResize(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
const right = (handle.parentElement as HTMLElement).getBoundingClientRect().right;
const move = (ev: PointerEvent) => setWidth(right - ev.clientX);
const up = () => {
resizing = false;
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function resizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') setWidth(width + 16);
else if (e.key === 'ArrowRight') setWidth(width - 16);
else return;
e.preventDefault();
}
let editing = $state(false); let editing = $state(false);
let draft = $state(''); let draft = $state('');
@@ -81,14 +52,8 @@
</script> </script>
<svelte:window onresize={() => setWidth(width)} /> <aside class="panel" aria-label="Details" style:width="{width}px">
<ResizeHandle bind:width edge="left" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.panelWidth" label="Resize panel" />
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
{#if panel.kind === 'members' || (!channel && !user)} {#if panel.kind === 'members' || (!channel && !user)}
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header> <header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
<Members {onnavigate} /> <Members {onnavigate} />
@@ -142,12 +107,6 @@
<h3>{store.displayName(user)}</h3> <h3>{store.displayName(user)}</h3>
<p class="where" title="Current channel"><Icon name="channel" size={14} /><span>{session.channel(user.channelId)?.name || 'Root'}</span></p> <p class="where" title="Current channel"><Icon name="channel" size={14} /><span>{session.channel(user.channelId)?.name || 'Root'}</span></p>
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if} {#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
<p class="badges">
{#if user.userId != null}<span class="badge-reg registered">Registered</span>{/if}
{#if isSelf && user.userId == null}
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
{/if}
</p>
<p class="flags"> <p class="flags">
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if} {#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if} {#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
@@ -192,9 +151,6 @@
<style> <style>
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; } .panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
.panel.resizing { user-select: none; }
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); } header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); } .tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
@@ -217,11 +173,6 @@
.where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; } h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); } h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0; }
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.register { font-size: 12px; color: var(--link); }
.register:hover { text-decoration: underline; }
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; } .flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); } .flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
.flags .bad { border-color: var(--danger); color: var(--danger); } .flags .bad { border-color: var(--danger); color: var(--danger); }
+4 -2
View File
@@ -5,8 +5,8 @@
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts'; import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings }: let { onadd, onedit, onsettings, onbrowse }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props(); { onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void; onbrowse: () => void } = $props();
const initials = (label: string) => const initials = (label: string) =>
label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?'; label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
@@ -50,6 +50,7 @@
function serverMenu(e: MouseEvent, s: SavedServer, i: number) { function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id); const state = stateOf(s.id);
menu.show(e, [ menu.show(e, [
{ header: true, label: s.label },
state === 'idle' state === 'idle'
? { label: 'Connect', action: () => sessions.open(s) } ? { label: 'Connect', action: () => sessions.open(s) }
: { label: 'Disconnect', action: () => sessions.close(s.id) }, : { label: 'Disconnect', action: () => sessions.close(s.id) },
@@ -87,6 +88,7 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if} {#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} /> <input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button> <button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>
<div class="spacer"></div> <div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button> <button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav> </nav>
+11 -3
View File
@@ -2,14 +2,16 @@
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
import VoiceSettings from './VoiceSettings.svelte'; import VoiceSettings from './VoiceSettings.svelte';
import IdentityList from './IdentityList.svelte'; import IdentityList from './IdentityList.svelte';
import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte';
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';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props(); let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'files' | 'identities'>(initialTab); let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab);
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('');
@@ -33,9 +35,11 @@
} }
} }
type Tab = 'voice' | 'files' | 'identities'; type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [ const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' }, { id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'files', label: 'Chat and files' }, { id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : []) ...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
]; ];
@@ -50,6 +54,10 @@
{#if tab === 'voice'} {#if tab === 'voice'}
<VoiceSettings /> <VoiceSettings />
{:else if tab === 'sounds'}
<SoundSettings />
{:else if tab === 'appearance'}
<AppearanceSettings />
{:else if tab === 'files'} {:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p> <p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label> <label for="s-host">Upload host</label>
+29 -6
View File
@@ -6,15 +6,37 @@
import { ui } from '../lib/ui.svelte.ts'; import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts'; import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts'; import { userMenu } from '../lib/actions.ts';
import { PERM } from '../core/client.ts';
import ResizeHandle from './ResizeHandle.svelte';
import { voice } from '../lib/audio/voice.svelte.ts'; import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props(); // resizable: false in the phone drawer, which keeps a fixed width
let { onnavigate, resizable = true }: { onnavigate: () => void; resizable?: boolean } = $props();
const MIN_W = 200, DEFAULT_W = 260;
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
let width = $state(Math.max(MIN_W, Number((() => { try { return localStorage.getItem('mumh5.sidebarWidth'); } catch { return null; } })()) || DEFAULT_W));
const roots = $derived(session.childrenOf(null)); const roots = $derived(session.childrenOf(null));
const self = $derived(session.self); const self = $derived(session.self);
const cert = $derived(session.serverCert); const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized); const trusted = $derived(!!cert?.authorized);
// Right-click on free space in the channel list (rows open their own menus and stop the event)
function listMenu(e: MouseEvent) {
if (session.status !== 'connected') return;
const here = session.self?.channelId ?? 0;
const hereName = session.channel(here)?.name || 'Root';
const noMake = (id: number) => session.can(id, PERM.MakeChannel) === false && session.can(id, PERM.MakeTempChannel) === false;
menu.show(e, [
{ label: 'Create channel...', disabled: noMake(0), action: () => (ui.channelDialog = { mode: 'create', parent: 0 }) },
...(here !== 0 ? [{ label: `Create channel in ${hereName}...`, disabled: noMake(here), action: () => (ui.channelDialog = { mode: 'create', parent: here }) }] : []),
{ separator: true, label: '' },
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) }
]);
}
function openQuick(e: MouseEvent, kind: 'input' | 'output') { function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault(); e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -36,7 +58,8 @@
); );
</script> </script>
<aside class="sidebar"> <aside class="sidebar" style:width={resizable ? `${width}px` : undefined}>
{#if resizable}<ResizeHandle bind:width edge="right" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
<header class:trusted> <header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}> <button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong> <strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
@@ -52,7 +75,7 @@
{/if} {/if}
</header> </header>
<div class="scroll"> <div class="scroll" role="presentation" oncontextmenu={listMenu}>
{#if session.status === 'connected'} {#if session.status === 'connected'}
<ul class="tree"> <ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each} {#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
@@ -72,7 +95,7 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{:else if session.error} {:else if session.error && !session.disconnectInfo}
<p class="error">{session.error}</p> <p class="error">{session.error}</p>
{:else if session.status === 'idle'} {:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p> <p class="hint">Pick a server on the left, or add one with the plus button.</p>
@@ -110,7 +133,7 @@
</aside> </aside>
<style> <style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; } .sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); } header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; } header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; } header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
@@ -135,7 +158,7 @@
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); } .elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.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; display: flex; align-items: center; gap: 8px; padding: 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); }
.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; }
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { sounds, SOUND_EVENTS, type SoundEvent } from '../lib/audio/sounds.svelte.ts';
const s = sounds.settings;
let picker: HTMLInputElement;
let packPicker: HTMLInputElement;
let folderPicker: HTMLInputElement;
const groups = [...new Set(SOUND_EVENTS.map(e => e.group))];
let target: SoundEvent | null = null;
let report = $state<{ assigned: string[]; unmatched: string[] } | null>(null);
let error = $state('');
function choose(ev: SoundEvent) {
target = ev;
picker.click();
}
async function picked() {
const f = picker.files?.[0];
picker.value = '';
if (!f || !target) return;
error = (await sounds.setCustom(target, f)) ?? '';
if (!error) sounds.play(target, true);
}
async function pack(input: HTMLInputElement) {
const files = [...(input.files ?? [])];
input.value = '';
if (files.length) report = await sounds.loadPack(files);
}
</script>
<h3>Notification sounds</h3>
<label class="check"><input type="checkbox" checked={s.enabled} onchange={e => sounds.update({ enabled: e.currentTarget.checked })} /> Play sounds for events</label>
<label for="snd-vol">Volume: {Math.round(s.volume * 100)}%</label>
<input id="snd-vol" type="range" min="0" max="1" step="0.05" value={s.volume} oninput={e => sounds.update({ volume: Number(e.currentTarget.value) })} />
<div class="packrow">
<button class="btn" onclick={() => folderPicker.click()}>Load sound pack folder...</button>
<button class="btn" onclick={() => packPicker.click()}>Load sound pack...</button>
<button class="btn" onclick={() => { sounds.resetAll(); report = null; }}>Reset all to built-in</button>
<input bind:this={folderPicker} type="file" webkitdirectory multiple hidden onchange={() => pack(folderPicker)} />
<input bind:this={packPicker} type="file" accept="audio/*,.wav,.mp3,.ogg,.opus,.flac,.m4a,.ini" multiple hidden onchange={() => pack(packPicker)} />
</div>
<p class="help">A TeamSpeak 3 sound pack folder (with its <code>settings.ini</code>) is mapped exactly. Other files are matched by name, e.g. <code>user_joined.wav</code>. Any sound can also be set one by one below.</p>
{#if report}
<div class="report">
{#if report.assigned.length}<p>Assigned: {report.assigned.join('; ')}</p>{/if}
{#if report.unmatched.length}<p class="warn">Not matched (set these by hand): {report.unmatched.join(', ')}</p>{/if}
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#each sounds.errors as err (err)}<p class="error">Could not play {err}</p>{/each}
<input bind:this={picker} type="file" accept="audio/*,.wav,.mp3,.ogg,.opus,.flac,.m4a" hidden onchange={picked} />
{#each groups as g (g)}
<h4>{g}</h4>
<ul class="events" class:disabled={!s.enabled}>
{#each SOUND_EVENTS.filter(e => e.group === g) as e (e.id)}
{@const custom = s.custom[e.id]}
<li>
<label class="check ev"><input type="checkbox" checked={sounds.isOn(e.id)} onchange={x => sounds.setOn(e.id, x.currentTarget.checked)} /> {e.name}</label>
<span class="file" title={custom?.name ?? 'Built-in sound'}>{custom?.name ?? 'Built-in'}</span>
<button class="icon-btn" title="Play" aria-label="Play {e.name}" onclick={() => sounds.play(e.id, true)}><Icon name="channel" size={16} /></button>
<button class="link" onclick={() => choose(e.id)}>Choose file</button>
{#if custom}<button class="link" onclick={() => sounds.reset(e.id)}>Reset</button>{/if}
</li>
{/each}
</ul>
{/each}
<style>
h3 { margin: 8px 0 0; font-size: 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: 10px; }
.check input { width: auto; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.packrow { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.packrow .btn { flex: none; }
h4 { margin: 16px 0 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.help { font-size: 12px; color: var(--text-dim); }
code { font: 12px var(--mono); }
.report { margin-top: 8px; font-size: 13px; color: var(--text-dim); }
.report p { margin: 4px 0 0; }
.warn { color: var(--warn); }
.error { color: var(--danger); font-size: 13px; }
.events { list-style: none; margin: 6px 0 0; padding: 0; border-top: 1px solid var(--line); }
.events.disabled { opacity: 0.5; }
.events li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line); }
.ev { flex: 1; margin: 0; min-width: 0; }
.file { max-width: 160px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: var(--link); font-size: 13px; white-space: nowrap; }
</style>
+247 -13
View File
@@ -130,7 +130,8 @@ try {
// Always on: a steady stream of 20 ms Opus packets // Always on: a steady stream of 20 ms Opus packets
const stream = collect(25); const stream = collect(25);
await setMode('Always on'); await setMode('Always on');
const packets = await within(stream, 'voice packets from the app'); // Generous: on a freshly started server and fake microphone the first packets can take a while
const packets = await within(stream, 'voice packets from the app', 20000);
assert.ok(packets.every(p => p.opus.length > 0 && !p.last)); assert.ok(packets.every(p => p.opus.length > 0 && !p.last));
const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame); const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame);
assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`); assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`);
@@ -200,6 +201,47 @@ try {
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0); assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
console.log('ok: incoming text rendered and sanitized'); console.log('ok: incoming text rendered and sanitized');
// Right-click menus for text: composer editing, links, messages, authors
const composer = page.locator('.chat .composer [contenteditable]');
const composerText = async () => (await composer.innerText()).trim();
const menuItem = (name: string) => page.getByRole('menuitem', { name, exact: true });
await composer.fill('abc 123');
await composer.click({ button: 'right' });
await menuItem('Select all').click();
await composer.click({ button: 'right' });
await menuItem('Cut').click();
assert.equal(await composerText(), '');
await composer.click({ button: 'right' });
await menuItem('Paste').click();
assert.equal(await composerText(), 'abc 123');
await composer.fill('');
await page.locator('.msg a[href="https://example.com/x"]').click({ button: 'right' });
await menuItem('Copy link address').click();
await composer.click({ button: 'right' });
await menuItem('Paste').click();
assert.equal(await composerText(), 'https://example.com/x');
await composer.fill('');
await page.locator('.msg .html', { hasText: 'hi alice' }).click({ button: 'right' });
await menuItem('Copy message text').waitFor();
await page.keyboard.press('Escape');
await page.locator('.msg header strong', { hasText: 'bob' }).first().click({ button: 'right' });
await menuItem('Send message').waitFor();
await page.keyboard.press('Escape');
console.log('ok: right-click menus for text fields, links, messages and authors');
// Formatting in the message box: toolbar behind the Aa button, sent as Mumble-compatible HTML
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
await composer.fill('bold words https://example.com/b');
await composer.press('Control+a');
const gotBold = new Promise<string>(res => bob.on('text', m => { if (m.html.includes('bold words')) res(m.html); }));
await page.locator('.chat .composer').getByRole('button', { name: 'Bold (Ctrl+B)' }).click();
await composer.press('Enter');
const bold = await within(gotBold, 'formatted message');
assert.match(bold, /(<b>|font-weight:bold)/, `bold arrives: ${bold}`);
assert.match(bold, /<a href="https:\/\/example\.com\/b">/, 'links still work in formatted messages');
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 become a click-to-play card; nothing loads from YouTube before the click
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 card = page.locator('.msg', { hasText: 'watch' }).locator('.yt');
@@ -217,6 +259,10 @@ try {
await page.locator('.dm', { hasText: 'bob' }).click(); await page.locator('.dm', { hasText: 'bob' }).click();
await page.locator('.msg', { hasText: 'psst' }).waitFor(); await page.locator('.msg', { hasText: 'psst' }).waitFor();
console.log('ok: direct message shown in its own view'); console.log('ok: direct message shown in its own view');
const soundLog = () => page.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
const played = await soundLog();
for (const ev of ['connected', 'message', 'privateMessage']) assert.ok(played.includes(ev), `sound for ${ev}: ${played}`);
console.log('ok: sounds for connect, channel and direct messages');
await page.locator('.channel .name').first().dblclick(); await page.locator('.channel .name').first().dblclick();
// Mute toggles propagate // Mute toggles propagate
@@ -243,6 +289,45 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: quick audio options on right-click'); console.log('ok: quick audio options on right-click');
// Color schemes apply right away and are remembered
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Windows 95' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
await page.getByRole('radio', { name: 'Dark' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: color schemes');
// Public server list, from a local list in the official XML format
const http = await import('node:http');
const listServer = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/xml' });
res.end(`<?xml version="1.0"?><servers><server name="E2E Murmur" ip="${host}" port="${port}" country="Testland" country_code="TL" continent_code="EU" url="https://example.org" ca="0"/><server name="Offline one" ip="127.0.0.1" port="9" country="Nowhere" continent_code="NA"/></servers>`);
}).listen(0);
const listPort = (listServer.address() as { port: number }).port;
await page.getByTitle('Browse public servers').click();
const pub = page.getByRole('dialog', { name: 'Public servers' });
await pub.locator('.link', { hasText: 'List source' }).click();
await page.getByLabel('URL').fill(`http://127.0.0.1:${listPort}/v1/list`);
await page.getByRole('button', { name: 'Use this list' }).click();
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: 'Add' }).click();
assert.equal(await page.getByLabel('Address').inputValue(), host);
assert.equal(await page.getByLabel('Port').inputValue(), port);
assert.equal(await page.getByLabel('Label').inputValue(), 'E2E Murmur');
await page.getByRole('button', { name: 'Cancel' }).click();
listServer.close();
console.log('ok: public server list with live ping and prefilled add');
// Server menus are titled with the server's name
await page.locator('.rail .tile').first().click({ button: 'right' });
await page.locator('.menu .head', { hasText: 'Test Server' }).waitFor();
await page.keyboard.press('Escape');
// Tray icon mirrors the voice state; its menu acts on it // Tray icon mirrors the voice state; its menu acts on it
const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && { const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && {
tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon
@@ -262,12 +347,50 @@ try {
console.log('ok: tray icon follows voice state and its menu mutes'); console.log('ok: tray icon follows voice state and its menu mutes');
await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor(); await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor();
console.log('ok: self mute propagated and shown'); console.log('ok: self mute propagated and shown');
assert.ok((await soundLog()).includes('mute'), 'mute sound');
// Sound pack in TeamSpeak 3 format: settings.ini maps events to files exactly
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Sounds' }).click();
// Real 16-bit PCM WAVs (a short tone), like the files in a TeamSpeak pack
const wav = (name: string) => {
const rate = 22050, n = rate / 10, b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 8) * 8000), 44 + i * 2);
return { name, mimeType: 'audio/wav', buffer: b };
};
const ini = { name: 'settings.ini', mimeType: 'text/plain', buffer: Buffer.from([
'[soundfiles]',
'SOUND_CAPTURE_MUTED = play("mic_muted.wav")',
'CHAT_RECEIVED_MESSAGE_CHANNEL = play("chat_message_inbound.wav")',
'CHAT_RECEIVED_MESSAGE_CLIENT = play("chat_message_inbound.wav")',
'CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS = play("${clientType}_switched_tocurrentchannel.wav")',
'CHANNEL_CREATED_BY_OTHER = '
].join('\n')) };
const packChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Load sound pack...' }).click();
await (await packChooser).setFiles([ini, wav('mic_muted.wav'), wav('chat_message_inbound.wav'),
wav('blue_switched_tocurrentchannel.wav'), wav('neutral_switched_tocurrentchannel.wav'), wav('extra.wav')]);
const rep = page.locator('.report');
for (const line of ['mic_muted.wav: You muted', 'chat_message_inbound.wav: Channel message', 'chat_message_inbound.wav: Direct message',
'neutral_switched_tocurrentchannel.wav: Someone joined your channel']) await rep.getByText(line).waitFor();
await rep.getByText(/Not matched.*extra\.wav/).waitFor();
assert.ok(!(await rep.textContent())!.includes('blue_switched_tocurrentchannel.wav: '), 'neutral variant preferred');
// A custom sound really plays (blob URL, allowed by the security policy) and survives a reload
const before = (await soundLog()).filter(x => x === 'mute').length;
await page.locator('.events li', { hasText: 'You muted' }).getByRole('button', { name: /^Play/ }).click();
await page.waitForFunction(n => ((window as any).__mumh5Sounds ?? []).filter((x: string) => x === 'mute').length > n, before);
assert.equal(await page.getByText(/^Could not play/).count(), 0, 'no playback errors');
await page.getByRole('button', { name: 'Reset all to built-in' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: TeamSpeak sound pack mapped from settings.ini');
if (process.env.UPLOAD_HOST) { if (process.env.UPLOAD_HOST) {
const png = path.join(userData, 'gradient.png'); const png = path.join(userData, 'gradient.png');
writeFileSync(png, testPng()); writeFileSync(png, testPng());
const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html))); const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html)));
await page.locator('.chat .composer input[type=file]').setInputFiles(png); await page.locator('.chat .composer input[type=file]').first().setInputFiles(png);
const up = await within(gotUpload, 'upload message'); const up = await within(gotUpload, 'upload message');
assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/); assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/);
assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/); assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/);
@@ -306,7 +429,7 @@ try {
writeFileSync(txt, 'plain text'); writeFileSync(txt, 'plain text');
let leaked = false; let leaked = false;
const off = bob.on('text', () => { leaked = true; }); const off = bob.on('text', () => { leaked = true; });
await page.locator('.chat .composer input[type=file]').setInputFiles(txt); await page.locator('.chat .composer input[type=file]').first().setInputFiles(txt);
await page.getByText('notes.txt: file type not allowed on this upload host').waitFor(); await page.getByText('notes.txt: file type not allowed on this upload host').waitFor();
await page.waitForTimeout(500); await page.waitForTimeout(500);
off(); off();
@@ -430,8 +553,8 @@ try {
admin.joinChannel(lobby.id); admin.joinChannel(lobby.id);
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); })); const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); }));
await page.locator('.panel textarea').fill('side hello'); await page.locator('.panel .composer [contenteditable]').fill('side hello');
await page.locator('.panel textarea').press('Enter'); await page.locator('.panel .composer [contenteditable]').press('Enter');
assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]); assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]);
await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor(); await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor();
assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0); assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0);
@@ -459,6 +582,101 @@ try {
const grown = (await page.locator('aside.panel').boundingBox())!; const grown = (await page.locator('aside.panel').boundingBox())!;
assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`); assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`);
console.log('ok: side panel resizable'); console.log('ok: side panel resizable');
// The channel list is resizable too (from its right edge)
const sideBox = (await page.locator('aside.sidebar').boundingBox())!;
const sideHandle = (await page.locator('aside.sidebar .resize').boundingBox())!;
await page.mouse.move(sideHandle.x + 4, sideHandle.y + 300);
await page.mouse.down();
await page.mouse.move(sideHandle.x + 104, sideHandle.y + 300, { steps: 5 });
await page.mouse.up();
const sideAfter = (await page.locator('aside.sidebar').boundingBox())!;
assert.ok(Math.abs(sideAfter.width - (sideBox.width + 100)) < 6, `sidebar resized ${sideBox.width} -> ${sideAfter.width}`);
await page.locator('aside.sidebar .resize').dblclick();
console.log('ok: channel list resizable');
// ─── Channel management and permissions, done by alice through the UI ───
// Give alice (by certificate hash) full rights first, as SuperUser. Not @all: Write
// overrides denies, and bob must stay an ordinary user for the permission checks.
const aliceHash = [...admin.users.values()].find(u => u.name === 'alice')!.hash;
const rootAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === 0) res(a); }));
admin.queryAcl(0);
const ra = await within(rootAcl, 'root ACL');
ra.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: `$${aliceHash}`, grant: 0x1f0fff, deny: 0 });
admin.saveAcl(ra);
await page.waitForTimeout(1500);
const chByName = (n: string) => [...bob.channels.values()].find(c => c.name === n);
const bobSees = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
if (cond()) return res();
const off = bob.on('channel', () => { if (cond()) { off(); res(); } });
const off2 = bob.on('channelRemove', () => { if (cond()) { off2(); res(); } });
}), label);
// Create from the context menu on free space in the channel list
const listBox = (await page.locator('.sidebar .scroll').boundingBox())!;
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
await page.getByLabel('Name').fill('Team');
await page.getByRole('button', { name: 'Create channel' }).click();
await page.locator('.channel .name', { hasText: 'Team' }).waitFor();
console.log('ok: channel created');
// Edit name and position
await page.locator('.channel .name', { hasText: 'Team' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Edit channel...' }).click();
await page.getByLabel('Name').fill('Team Room');
await page.getByLabel('Position').fill('7');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await bobSees('rename', () => chByName('Team Room')?.position === 7);
const teamId = chByName('Team Room')!.id;
console.log('ok: channel edited');
// Permissions: deny Enter for everyone
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Permissions...' }).click();
const dlg = page.getByRole('dialog', { name: 'Channel: Team Room' });
await dlg.getByRole('listbox', { name: 'Rules' }).waitFor();
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
await dlg.getByLabel('Deny Enter').check();
if (shots) await page.screenshot({ path: path.join(shots, 'acl.png') });
// Group with a registered member
await dlg.getByRole('tab', { name: 'Groups' }).click();
await dlg.getByLabel('New group name').fill('mods');
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
await dlg.getByLabel('Member name').fill('SuperUser');
await dlg.getByRole('button', { name: 'Add member' }).click();
await dlg.locator('.members', { hasText: 'SuperUser' }).waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'groups.png') });
await dlg.getByRole('button', { name: 'Save', exact: true }).click();
await page.waitForTimeout(1500);
const deniedEnter = new Promise<number>(res => bob.on('permissionDenied', d => res(d.permission)));
bob.joinChannel(teamId);
assert.equal(await within(deniedEnter, 'enter denied'), 0x4);
const teamAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === teamId) res(a); }));
admin.queryAcl(teamId);
const ta = await within(teamAcl, 'team ACL');
assert.deepEqual(ta.groups.find((g: any) => g.name === 'mods')?.add, [0]);
console.log('ok: permissions and groups saved and enforced');
// Drag the channel into Games, and bob into Lobby
await page.locator('.channel .name', { hasText: 'Team Room' }).dragTo(page.locator('.channel .name', { hasText: 'Games' }));
const gamesId = chByName('Games')!.id;
await bobSees('channel moved', () => chByName('Team Room')?.parent === gamesId);
await page.locator('.sidebar .user', { hasText: 'bob' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first());
await within(new Promise<void>(res => {
if (bob.self?.channelId === lobby.id) return res();
bob.on('user', u => { if (u.session === bob.session && u.channelId === lobby.id) res(); });
}), 'bob moved');
bob.joinChannel(0);
console.log('ok: drag and drop moves channels and people');
// Delete (Team Room is now inside Games, which stays collapsed while empty)
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Delete channel...' }).click();
await page.getByRole('button', { name: 'Delete channel' }).click();
await bobSees('deleted', () => !chByName('Team Room'));
console.log('ok: channel deleted');
} }
// Without an upload host, images are sent inline within Mumble's image limit // Without an upload host, images are sent inline within Mumble's image limit
@@ -471,7 +689,7 @@ try {
const big = path.join(userData, 'big.png'); const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200)); writeFileSync(big, testPng(1600, 1200));
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); })); const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
await page.locator('.chat .composer input[type=file]').setInputFiles(big); await page.locator('.chat .composer input[type=file]').first().setInputFiles(big);
const inline = await within(gotInline, 'inline image', 15000); const inline = await within(gotInline, 'inline image', 15000);
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/); assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`); assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
@@ -496,6 +714,7 @@ try {
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true); assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true);
await page.getByText('Voice is on Test Server').waitFor(); await page.getByText('Voice is on Test Server').waitFor();
console.log('ok: second server connected, deafened in the background'); console.log('ok: second server connected, deafened in the background');
assert.ok((await soundLog()).includes('userConnected'), 'sound when someone connects into your channel');
// Joining a channel on the second server moves voice there // Joining a channel on the second server moves voice there
await page.locator('.channel .name', { hasText: 'Root' }).dblclick(); await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
@@ -561,15 +780,30 @@ try {
}), 'bob sees the new name'); }), 'bob sees the new name');
console.log('ok: name changed by reconnecting'); console.log('ok: name changed by reconnecting');
// Self-registration: a guest has no badge and a Register button; afterwards the Registered badge shows // Self-registration from the right-click menu on yourself; the profile shows no badge either way
await page.locator('.me .profile').click(); const meRow = page.locator('.sidebar .user.self');
await page.locator('.panel .register').waitFor(); await meRow.click({ button: 'right' });
if (await page.locator('.panel .badge-reg').count()) throw new Error('guest profile shows a badge'); await page.getByRole('menuitem', { name: 'Register on this server' }).click();
await page.locator('.panel .register').click(); await meRow.locator('.reg').waitFor();
await page.locator('.panel .badge-reg.registered', { hasText: 'Registered' }).waitFor(); await meRow.click({ button: 'right' });
await page.locator('.sidebar .user', { hasText: newName }).locator('.reg').waitFor(); assert.equal(await page.getByRole('menuitem', { name: 'Register on this server' }).count(), 0, 'no register item once registered');
await page.keyboard.press('Escape');
console.log('ok: registered on the server'); console.log('ok: registered on the server');
// Kicked: the reason is shown with a Reconnect button
if (admin) {
const me = [...admin.users.values()].find(u => u.name === newName)!;
admin.kick(me.session, 'testing kicks');
const card = page.locator('.ended');
await card.getByText('You were kicked by SuperUser').waitFor();
await card.getByText('testing kicks').waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'kicked.png') });
await card.getByRole('button', { name: 'Reconnect' }).click();
await page.locator('.sidebar .status', { hasText: 'Connected' }).waitFor();
assert.equal(await card.count(), 0);
console.log('ok: kick shows the reason and reconnects');
}
if (shots) { if (shots) {
await page.waitForTimeout(300); await page.waitForTimeout(300);
for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) { for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) {
+12
View File
@@ -132,6 +132,18 @@ await wait(800);
await page.screenshot({ path: path.join(out, 'voice-settings.png') }); await page.screenshot({ path: path.join(out, 'voice-settings.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.getByRole('button', { name: 'Done', exact: true }).click();
// Every color scheme, main view with the member list
await page.locator('.panel .icon-btn[aria-label="Close"]').click().catch(() => {});
for (const t of ['light', 'midnight', 'frost', 'win95', 'dark']) {
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.locator(`[data-theme-preview="${t}"]`).click();
if (t === 'win95') await page.screenshot({ path: path.join(out, 'settings-appearance.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await wait(400);
if (t !== 'dark') await page.screenshot({ path: path.join(out, `theme-${t}.png`) });
}
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.scrim').first().click().catch(() => {}); await page.locator('.scrim').first().click().catch(() => {});
await wait(500); await wait(500);
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parsePublicList, pingServer } from '../electron/publist.ts';
test('parses the public server list XML', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?><servers>
<server name="Tom &amp; Jerry" ca="1" continent_code="EU" country="Germany" country_code="DE" ip="voice.example.org" port="64738" region="Hessen" url="https://example.org"/>
<server name="No port" ip="10.0.0.1" country_code="US" continent_code="NA" url="javascript:alert(1)"/>
<server name="<C> Contra Clan" ca="0" continent_code="NA" country="United States" country_code="US" ip="mumble.poonbox.com" port="64738" url="www.poonbox.com"/>
<server name="" ip="bad"/>
</servers>`;
assert.deepEqual(parsePublicList(xml), [
{ name: 'Tom & Jerry', host: 'voice.example.org', port: 64738, url: 'https://example.org', country: 'Germany', countryCode: 'de', continent: 'eu', ca: true },
{ name: 'No port', host: '10.0.0.1', port: 64738, url: '', country: 'Unknown', countryCode: 'us', continent: 'na', ca: false },
{ name: '<C> Contra Clan', host: 'mumble.poonbox.com', port: 64738, url: 'http://www.poonbox.com', country: 'United States', countryCode: 'us', continent: 'na', ca: false }
]);
});
const target = process.env.MUMBLE_TEST_HOST;
test('UDP ping reports users and version', { skip: !target }, async () => {
const [host, port] = target!.split(':');
const r = await pingServer(host, Number(port));
assert.ok(r, 'server answered');
assert.match(r!.version, /^1\.\d+\.\d+$/);
assert.ok(r!.maxUsers > 0 && r!.ping >= 0);
assert.equal(await pingServer('127.0.0.1', 9, 500), null, 'no answer gives null');
});
+73 -2
View File
@@ -12,7 +12,7 @@ import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
const target = process.env.MUMBLE_TEST_HOST; const target = process.env.MUMBLE_TEST_HOST;
const codec = createCodec(); const codec = createCodec();
function connect(name: string): Promise<MumbleClient> { function connect(name: string, password = ''): Promise<MumbleClient> {
const [host, port] = target!.split(':'); const [host, port] = target!.split(':');
const id = generateIdentity(name); const id = generateIdentity(name);
const client = new MumbleClient(codec); const client = new MumbleClient(codec);
@@ -22,7 +22,7 @@ function connect(name: string): Promise<MumbleClient> {
onData: c => transport.onData?.(c), onData: c => transport.onData?.(c),
onClose: r => transport.onClose?.(r) onClose: r => transport.onClose?.(r)
}); });
client.connect(transport, { username: name, os: 'test' }); client.connect(transport, { username: name, password, os: 'test' });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
client.on('synced', () => resolve(client)); client.on('synced', () => resolve(client));
client.on('close', reject); client.on('close', reject);
@@ -161,3 +161,74 @@ test('irregular bursts match the server bucket (whole-second leaks)', { skip: !t
} }
}); });
const superPw = process.env.MUMBLE_SUPERUSER_PASSWORD;
test('channel management and ACLs as SuperUser', { skip: !target || !superPw }, async () => {
const admin = await connect('SuperUser', superPw);
const guest = await connect('guest' + Date.now() % 1000);
try {
const name = 'acltest' + Date.now() % 100000;
const byName = (n: string) => [...admin.channels.values()].find(c => c.name === n);
const channelEvent = (pred: () => boolean) => until<void>(res => {
if (pred()) return res();
const off = admin.on('channel', () => { if (pred()) { off(); res(); } });
});
// Create, rename, describe, move
admin.createChannel(0, name);
await channelEvent(() => !!byName(name));
const id = byName(name)!.id;
admin.updateChannel(id, { name: name + 'x', description: 'about <b>this</b>', position: 3 });
await channelEvent(() => admin.channels.get(id)?.name === name + 'x');
assert.equal(admin.channels.get(id)!.description, 'about <b>this</b>');
admin.createChannel(0, name + 'sub');
await channelEvent(() => !!byName(name + 'sub'));
const sub = byName(name + 'sub')!.id;
admin.updateChannel(sub, { parent: id });
await channelEvent(() => admin.channels.get(sub)?.parent === id);
// Link and unlink
admin.setLinks(id, [0], []);
await channelEvent(() => !!admin.channels.get(id)?.links.has(0));
admin.setLinks(id, [], [0]);
await channelEvent(() => !admin.channels.get(id)?.links.has(0));
// ACL: deny Enter for everyone in the channel, and a group with a registered member
const acl = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
admin.queryAcl(id);
const current = await acl;
assert.ok(current.acls.some((a: any) => a.inherited), 'inherited rules from root are listed');
current.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0x4 });
current.groups.push({ name: 'mods', inherited: false, inherit: true, inheritable: true, add: [0], remove: [], inheritedMembers: [] });
admin.saveAcl(current);
const saved = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
setTimeout(() => admin.queryAcl(id), 300);
const after = await saved;
assert.ok(after.acls.some((a: any) => !a.inherited && a.group === 'all' && a.deny === 0x4), 'rule saved');
assert.deepEqual(after.groups.find((g: any) => g.name === 'mods')?.add, [0], 'group member saved');
// The rule is enforced: a guest may not enter
const denied = until<number>(res => guest.on('permissionDenied', d => res(d.permission)));
guest.joinChannel(id);
assert.equal(await denied, 0x4);
// Permissions: a guest may traverse the channel but not enter it; SuperUser gets no
// permission messages at all and is treated as allowed everything
const perms = until<number>(res => guest.on('permissions', (c, bits) => { if (c === id) res(bits); }));
guest.requestPermissions(id);
const bits = await perms;
assert.equal(bits & 0x4, 0, 'guest cannot enter');
assert.ok(bits & 0x2, 'guest can traverse');
assert.equal(guest.can(id, 0x4), false);
assert.equal(admin.can(id, 0x1), true);
// Remove (subchannel goes with it)
admin.removeChannel(id);
await until<void>(res => admin.on('channelRemove', c => { if (c === id) res(); }));
assert.ok(!admin.channels.has(sub));
} finally {
admin.disconnect();
guest.disconnect();
}
});