diff --git a/CLAUDE.md b/CLAUDE.md index 6d14157..7fe3aed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - 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) - `$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. - 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. +- 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 (`$` 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 diff --git a/README.md b/README.md index df26759..534a86b 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | +| 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 - 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 +- 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) @@ -79,8 +84,20 @@ mumh5 keeps the foundation and replaces the experience. - Incoming HTML is sanitized; remote images from unknown hosts are never loaded - 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 - 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 - Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity - Password-protected backups of your identity diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index 24a0e48..876c251 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/mobile.png b/docs/screenshots/mobile.png index 952c9c7..ef6095d 100644 Binary files a/docs/screenshots/mobile.png and b/docs/screenshots/mobile.png differ diff --git a/docs/screenshots/profile.png b/docs/screenshots/profile.png index c0c7092..3f49f6a 100644 Binary files a/docs/screenshots/profile.png and b/docs/screenshots/profile.png differ diff --git a/docs/screenshots/settings-appearance.png b/docs/screenshots/settings-appearance.png new file mode 100644 index 0000000..071e553 Binary files /dev/null and b/docs/screenshots/settings-appearance.png differ diff --git a/docs/screenshots/theme-frost.png b/docs/screenshots/theme-frost.png new file mode 100644 index 0000000..ebde921 Binary files /dev/null and b/docs/screenshots/theme-frost.png differ diff --git a/docs/screenshots/theme-light.png b/docs/screenshots/theme-light.png new file mode 100644 index 0000000..5e75e90 Binary files /dev/null and b/docs/screenshots/theme-light.png differ diff --git a/docs/screenshots/theme-midnight.png b/docs/screenshots/theme-midnight.png new file mode 100644 index 0000000..13f76dd Binary files /dev/null and b/docs/screenshots/theme-midnight.png differ diff --git a/docs/screenshots/theme-win95.png b/docs/screenshots/theme-win95.png new file mode 100644 index 0000000..6b263d6 Binary files /dev/null and b/docs/screenshots/theme-win95.png differ diff --git a/docs/screenshots/voice-settings.png b/docs/screenshots/voice-settings.png index 038687c..8b2f2d9 100644 Binary files a/docs/screenshots/voice-settings.png and b/docs/screenshots/voice-settings.png differ diff --git a/electron/main.ts b/electron/main.ts index aa589c1..363698b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 os from 'node:os'; import path from 'node:path'; @@ -6,6 +6,7 @@ import { IdentityStore } from './identity-store.ts'; import { describeCert } from './certs.ts'; import * as tray from './tray.ts'; import { fetchLinkPreview } from './link-preview.ts'; +import { fetchPublicListWith, pingServer } from './publist.ts'; import { openTls } from './tls-transport.ts'; 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[]) => (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.on('tray:update', (_e, state: tray.TrayState) => tray.update(state)); @@ -124,6 +145,15 @@ function createWindow() { 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('hide', () => tray.onWindowVisibility()); win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); diff --git a/electron/preload.ts b/electron/preload.ts index 61a6c0a..831df59 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -13,6 +13,10 @@ contextBridge.exposeInMainWorld('mumh5Native', { platformInfo: () => ipcRenderer.invoke('platform:info'), updateTray: (state: unknown) => ipcRenderer.send('tray:update', state), 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)); }, describeCerts: (ders: Uint8Array[]) => ipcRenderer.invoke('certs:describe', ders), identities: { diff --git a/electron/publist.ts b/electron/publist.ts new file mode 100644 index 0000000..883421a --- /dev/null +++ b/electron/publist.ts @@ -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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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=""), so step over quoted strings + for (const [, attrs] of xml.matchAll(/"]|"[^"]*")*?)\/?>/g)) { + const a: Record = {}; + 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, url: string): Promise { + 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 { + 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); }); + }); +} diff --git a/src/App.svelte b/src/App.svelte index dbfdde3..ebe0237 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,10 +12,12 @@ import IdentityWizard from './ui/IdentityWizard.svelte'; import QuickAudio from './ui/QuickAudio.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 { session } from './lib/session.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); // 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'); + // 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 let lastLayout = ''; $effect(() => { @@ -53,9 +62,9 @@
{#if layout !== 'narrow' || navOpen} {#if layout === 'narrow'}{/if} {/if} @@ -73,6 +82,8 @@
{#if editing} (editing = null)} />{/if} +{#if ui.addServer} (ui.addServer = null)} />{/if} +{#if ui.publicServers} (ui.publicServers = false)} />{/if} {#if ui.settingsOpen} { ui.settingsOpen = false; ui.settingsTab = 'voice'; }} />{/if} {#if ui.quickAudio}{/if} {#if ui.userInfo != null && session.status === 'connected'} @@ -82,6 +93,9 @@ {#if ui.cert} (ui.cert = null)} />{/if} {#if identities.loaded && !identities.setupDone} {}} /> {:else if ui.wizard} (ui.wizard = null)} />{/if} +{#if ui.channelDialog && session.status === 'connected'} + {#key ui.channelDialog} (ui.channelDialog = null)} />{/key} +{/if} {#if ui.prompt} (ui.prompt = null)} />{/if} diff --git a/src/app.css b/src/app.css index 082cbd9..c549161 100644 --- a/src/app.css +++ b/src/app.css @@ -20,6 +20,8 @@ --font: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, sans-serif; --mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace; --fs: 15px; + /* Shared height of the bottom bars: your user panel and the message input line up */ + --footer-h: 52px; color-scheme: dark; } diff --git a/src/core/client.ts b/src/core/client.ts index 68fb999..72243c4 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -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 { username: string; password?: string; @@ -122,6 +156,9 @@ type ClientEvents = { reject: (type: number, reason: string) => void; voice: (packet: Uint8Array) => void; userStats: (stats: any) => void; + acl: (acl: ChannelAcl) => void; + userNames: (names: Map) => void; + permissions: (channelId: number | null, bits: number) => void; ping: (rttMs: number) => void; close: (reason: string) => void; message: (name: MessageName, msg: any) => void; @@ -130,6 +167,10 @@ type ClientEvents = { export class MumbleClient extends Emitter { readonly channels = new Map(); readonly users = new Map(); + // Our effective permissions per channel (PermissionQuery); cleared when the server flushes + readonly permissions = new Map(); + // Registered user names by id, learned from QueryUsers + readonly registeredNames = new Map(); session: number | null = null; synced = false; welcomeText = ''; @@ -276,6 +317,74 @@ export class MumbleClient extends Emitter { 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 = { 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 requestUserStats(session: number, statsOnly = false): void { this.send('UserStats', { session, stats_only: statsOnly }); @@ -367,6 +476,7 @@ export class MumbleClient extends Emitter { this.session = msg.session; this.maxBandwidth = msg.max_bandwidth ?? 0; this.welcomeText = msg.welcome_text ?? ''; + if (msg.permissions != null) this.permissions.set(0, Number(msg.permissions)); this.synced = true; this.ping(); this.emit('synced'); @@ -419,6 +529,35 @@ export class MumbleClient extends Emitter { case 'UserStats': this.emit('userStats', msg); 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': if (msg.senderSession != null && msg.dataID) { this.emit('pluginData', msg.senderSession, msg.dataID, msg.data ?? new Uint8Array(0)); diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 7bcdb26..468fdfb 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -2,13 +2,41 @@ import { session } from './session.svelte.ts'; import { ui } from './ui.svelte.ts'; import { store } from './settings.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 { 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 export function channelMenu(c: Channel): MenuItem[] { 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 [ + { header: true, label: c.name || 'Root' }, { 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: '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: 'Collapse all', action: () => session.setAllExpanded(false) }, { 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 isSelf = u.session === self?.session; const items: MenuItem[] = [ + { header: true, label: store.displayName(u) }, { label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } } ]; if (isSelf) { diff --git a/src/lib/audio/sounds.svelte.ts b/src/lib/audio/sounds.svelte.ts new file mode 100644 index 0000000..00ff015 --- /dev/null +++ b/src/lib/audio/sounds.svelte.ts @@ -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>; + // File names only; the audio itself lives in IndexedDB (key "sound:") + custom: Partial>; +} + +const MAX_FILE = 1024 * 1024; + +// Default tones: [frequency Hz, start s, length s] per event, with a short fade +const TONES: Record = { + 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 { + const out = new Map(); + 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 { + 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(load('mumh5.sounds', { enabled: true, volume: 0.6, on: {}, custom: {} })); + private defaults = new Map>(); + // Object URLs of custom sounds; blob: is what the content security policy allows for media + private urls = new Map(); + private ready: Promise; + errors = $state([]); + + 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 { + 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(); + + private persist(): void { + save('mumh5.sounds', $state.snapshot(this.settings)); + } + + update(patch: Partial>): 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 { + 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(); + 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 { + 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 { + 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 }; + 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(); diff --git a/src/lib/audio/voice.svelte.ts b/src/lib/audio/voice.svelte.ts index 9281697..2b3a91d 100644 --- a/src/lib/audio/voice.svelte.ts +++ b/src/lib/audio/voice.svelte.ts @@ -80,6 +80,11 @@ class VoiceEngine { private client: MumbleClient | null = null; private sessionGains = new Map(); 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 sampleTime = 0; // encoder timestamps, microseconds @@ -301,6 +306,12 @@ class VoiceEngine { if (db > s.vadThreshold) this.lastVoiceAt = now; 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'; 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 const t = e.target as HTMLElement | null; if (down && t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + if (this.pttDown === down) return; 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 ────────────────────────────────────────────────────────────── diff --git a/src/lib/blobstore.ts b/src/lib/blobstore.ts new file mode 100644 index 0000000..876ec46 --- /dev/null +++ b/src/lib/blobstore.ts @@ -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 { + 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(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest): Promise { + return open().then(db => new Promise((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('readonly', s => s.get(key)); +export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key)); diff --git a/src/lib/drag.svelte.ts b/src/lib/drag.svelte.ts new file mode 100644 index 0000000..9972e20 --- /dev/null +++ b/src/lib/drag.svelte.ts @@ -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 }); diff --git a/src/lib/html.ts b/src/lib/html.ts index 6c56467..8e1ffb3 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -160,9 +160,32 @@ function serialize(node: Node): string { return `<${tag}${attrs}>${[...el.childNodes].map(serialize).join('')}`; } +// 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 -export function toMumbleHtml(html: string): string { +export function toMumbleHtml(html: string, opts: { linkify?: boolean } = {}): string { const frag = DOMPurify.sanitize(html, purifyConfig); + if (opts.linkify) linkifyNodes(frag); return [...frag.childNodes].map(serialize).join('') .replace(/(

<\/p>)+$/, '') .replace(/^()+|()+$/g, '') diff --git a/src/lib/icons.ts b/src/lib/icons.ts index b924847..b5f3fd1 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -21,6 +21,7 @@ export const ICON_PATHS: Record = { server: 'M2 3h20v8H2zM2 13h20v8H2zM6 7h.01M6 17h.01', shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', 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-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', diff --git a/src/lib/menu.svelte.ts b/src/lib/menu.svelte.ts index e5754af..bdf4ddd 100644 --- a/src/lib/menu.svelte.ts +++ b/src/lib/menu.svelte.ts @@ -5,13 +5,16 @@ export interface MenuItem { checked?: boolean; disabled?: 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 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 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 { e.preventDefault(); @@ -19,6 +22,10 @@ class MenuStore { 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 { this.open = null; } diff --git a/src/lib/native.ts b/src/lib/native.ts index fbb6407..f2da03e 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -6,6 +6,10 @@ interface NativeApi { updateTray(state: { icon: string; tooltip: string; connected: boolean; server: string; muted: boolean; deafened: boolean; mode: string }): void; onTrayAction(fn: (action: string) => void): void; linkPreview(url: string): Promise; + publicServers(url: string): Promise; + pingServer(host: string, port: number): Promise; + onContextMenu(fn: (params: ContextParams) => void): void; + editAction(action: string, arg?: unknown): void; identities: { list(): Promise<{ identities: IdentitySummary[]; setupDone: boolean }>; create(name: string, email: string): Promise; @@ -24,6 +28,23 @@ interface NativeApi { 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 { url: string; title: string; diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 11504d4..1011b96 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -6,6 +6,7 @@ import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts'; import { voice } from './audio/voice.svelte.ts'; +import { sounds } from './audio/sounds.svelte.ts'; const codec = createCodec(); @@ -59,6 +60,11 @@ export class Session { uploads = $state([]); certPrompt = $state(null); 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(); // TLS details of the current server, for the certificate viewer and the trusted badge serverCert = $state(null); uploadInfo = $state(null); @@ -155,6 +161,22 @@ export class Session { 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 { const [kind, id] = key.split(':'); if (kind === 'ch') return this.channel(Number(id))?.name || 'Root'; @@ -181,6 +203,8 @@ export class Session { this.panel = { kind: 'members' }; this.requested.clear(); this.serverCert = null; + this.disconnectInfo = null; + this.removedBy = null; this.status = 'connecting'; if (!native) { @@ -226,6 +250,10 @@ export class Session { }); } + reconnect(): void { + if (this.server) this.connect($state.snapshot(this.server) as SavedServer); + } + acceptCertificate(): void { const p = this.certPrompt; if (!p) return; @@ -258,10 +286,23 @@ export class Session { this.status = 'connected'; this.view = `ch:${client.self?.channelId ?? 0}`; 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); 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 => { if (!live()) return; 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('user', (user, changed, _actor, isNew) => { + client.on('user', (user, changed, actor, isNew) => { if (!live()) return; 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 (isNew) { 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) => { 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; + 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) { 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'; @@ -304,7 +358,11 @@ export class Session { 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 => { if (!live()) return; @@ -317,12 +375,23 @@ export class Session { return; } 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; 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 => { 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; sessions.onClosed(this); 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 ───────────────────────────────────────── showChannel(id: number, tab: 'info' | 'chat' = 'info'): void { @@ -472,6 +564,7 @@ export class Session { return false; } client.sendText(this.target(key), html); + sounds.play('messageSent'); const rendered = renderIncoming(html, store.trustedHosts); this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered }); return true; @@ -514,8 +607,10 @@ export class Session { if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() }; this.sendHtml(html, key); this.uploads = this.uploads.filter(j => j.id !== job.id); + sounds.play('uploadDone'); } catch (e) { live().error = (e as Error).message; + sounds.play('uploadFailed'); setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000); } } diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 3c967b8..8ed3cc7 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -21,8 +21,20 @@ export interface Settings { youtube: 'off' | 'click' | 'thumbnail'; // YouTube links: plain, click-to-play card, card with thumbnail // Link previews: fetched by the upload host (private), by this computer (reveals IP), or none linkPreviews: 'host' | 'direct' | 'off'; + 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 = { uploadHost: '', uploadKey: '', @@ -31,7 +43,9 @@ const defaults: Settings = { sendThumbnails: true, compact: false, youtube: 'click', - linkPreviews: 'host' + linkPreviews: 'host', + theme: 'dark', + publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0' }; class SettingsStore { diff --git a/src/lib/text-menu.ts b/src/lib/text-menu.ts new file mode 100644 index 0000000..f8e341f --- /dev/null +++ b/src/lib/text-menu.ts @@ -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); + }); +} diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index 24ebb73..c58214a 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -25,7 +25,12 @@ class Ui { wizard = $state<'setup' | 'add' | null>(null); // Quick audio popover from right-clicking mute (input) or deafen (output) 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 userInfo = $state(null); // Set when a panel was opened on a layout where it is an overlay diff --git a/src/main.ts b/src/main.ts index 037036c..e2c8b9c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,11 @@ import { mount } from 'svelte'; import './app.css'; import App from './App.svelte'; +// After the components' styles, so themes override them +import './themes.css'; import { startTray } from './lib/tray.svelte.ts'; +import { startTextMenu } from './lib/text-menu.ts'; mount(App, { target: document.getElementById('app')! }); startTray(); +startTextMenu(); diff --git a/src/themes.css b/src/themes.css new file mode 100644 index 0000000..7faf7ba --- /dev/null +++ b/src/themes.css @@ -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"); +} diff --git a/src/ui/AppearanceSettings.svelte b/src/ui/AppearanceSettings.svelte new file mode 100644 index 0000000..7c82b64 --- /dev/null +++ b/src/ui/AppearanceSettings.svelte @@ -0,0 +1,52 @@ + + +

Color scheme

+
+ {#each THEMES as t (t.id)} + + {/each} +
+ + diff --git a/src/ui/ChannelDialog.svelte b/src/ui/ChannelDialog.svelte new file mode 100644 index 0000000..a8b8ceb --- /dev/null +++ b/src/ui/ChannelDialog.svelte @@ -0,0 +1,438 @@ + + + + {#if mode === 'edit'} +
+ + + +
+ {/if} + + {#if tab === 'general'} + + + + {#if descLoaded} + (description = h)} /> + {:else} +

Loading description...

+ {/if} +
+
+ + +
+
+ + +
+ {#if mode === 'create'} + + {/if} +
+

Lower positions come first. A user limit of 0 uses the server default.

+ {:else if !acl} +

Loading permissions...

+ {:else if tab === 'permissions'} + +
+
+
    + {#each acl.acls as r, i (i)} +
  • + +
  • + {:else} +
  • No rules yet
  • + {/each} +
+
+ + + + +
+
+
+ {#if rule} + {#if rule.inherited}

Inherited from a parent channel. Edit it there, or turn off inheriting above.

{/if} +
+
+ + + {#each groupSuggestions as g (g)}{/each} +
+
+ + +
+ + + + {#each permList as p (p.bit)} + + + + + + {/each} + +
PermissionAllowDeny
{p.name} setBit('grant', p.bit, e.currentTarget.checked)} /> setBit('deny', p.bit, e.currentTarget.checked)} />
+
+ {:else} +

Select a rule, or add one.

+ {/if} +
+
+ {:else} +
+
+
    + {#each acl.groups as g (g.name)} +
  • + +
  • + {:else} +
  • No groups yet
  • + {/each} +
+
+ { if (e.key === 'Enter') addGroup(); }} /> + + +
+
+
+ {#if group} + + +

Members added here

+
    + {#each group.add as id (id)} +
  • {userName(id)}
  • + {:else} +
  • Nobody yet
  • + {/each} +
+
+ { if (e.key === 'Enter') addMember(); }} /> + +
+ {#if group.inheritedMembers.length} +

Inherited members

+
    + {#each group.inheritedMembers as id (id)} + {@const excluded = group.remove.includes(id)} +
  • {userName(id)} +
  • + {/each} +
+ {/if} + {:else} +

Select a group, or add one.

+ {/if} +
+
+ {/if} + + {#if error}

{error}

{/if} + + {#snippet footer()} + + + {/snippet} +
+ + diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index 0f74faa..d672b43 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -8,6 +8,8 @@ import { channelMenu, userMenu } from '../lib/actions.ts'; import { voice } from '../lib/audio/voice.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'; 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( () => { session.showChannel(channel.id); ui.panelOpen = true; }, () => { session.joinChannel(channel.id); onnavigate(); } @@ -37,8 +65,11 @@
  • -
    menu.show(e, channelMenu(channel))} role="presentation"> +
    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} + + {#snippet empty()}
    @@ -38,7 +57,8 @@

    Connecting...

    {:else}

    mumh5

    -

    Choose a server to get started.

    +

    Choose a server to get started, or find one:

    + {/if}
    {/snippet} @@ -59,4 +79,11 @@ .empty { padding: 32px 16px 8px; } .empty h2 { margin: 0 0 4px; font-size: 24px; } .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; } diff --git a/src/ui/Composer.svelte b/src/ui/Composer.svelte index 982cabd..f160980 100644 --- a/src/ui/Composer.svelte +++ b/src/ui/Composer.svelte @@ -1,48 +1,112 @@ + + + + diff --git a/src/ui/Message.svelte b/src/ui/Message.svelte index c6b7c74..dcab2ee 100644 --- a/src/ui/Message.svelte +++ b/src/ui/Message.svelte @@ -3,11 +3,20 @@ import YouTubeEmbed from './YouTubeEmbed.svelte'; import LinkPreview from './LinkPreview.svelte'; 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'; let { entry, grouped }: { entry: ChatEntry; grouped: boolean } = $props(); 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' })); @@ -22,13 +31,13 @@ {#if grouped} {hhmm} {:else} - + {/if}
    {#if !grouped}
    - {entry.author} + {entry.author}
    {/if} diff --git a/src/ui/PublicServers.svelte b/src/ui/PublicServers.svelte new file mode 100644 index 0000000..93d81ca --- /dev/null +++ b/src/ui/PublicServers.svelte @@ -0,0 +1,172 @@ + + + +
    + + + +
    + + {#if loading} +

    Loading the public server list...

    + {:else if error} +

    Could not load the list: {error}

    +
    + {:else} + + + + {#each shown as s (key(s))} + {@const p = pings[key(s)]} + + + + + + + + {:else} + + {/each} + +
    ServerCountryPeoplePing
    + {s.name} + {s.host}{s.port !== 64738 ? `:${s.port}` : ''} + {#if s.url}website{/if} + {s.country}{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''} 180}>{p ? `${Math.round(p.ping)} ms` : ''}
    No servers match.
    +

    {servers.length} servers listed{shown.length < servers.length ? `, ${shown.length} shown` : ''}. Pinging lets each server see your IP address, like in the desktop client.

    + {/if} + + {#snippet footer()} + + + + {/snippet} +
    + + diff --git a/src/ui/ResizeHandle.svelte b/src/ui/ResizeHandle.svelte new file mode 100644 index 0000000..a5d2ce6 --- /dev/null +++ b/src/ui/ResizeHandle.svelte @@ -0,0 +1,60 @@ + + + setWidth(width)} /> + + + + + + diff --git a/src/ui/RichEditor.svelte b/src/ui/RichEditor.svelte index e63f83c..ba2d075 100644 --- a/src/ui/RichEditor.svelte +++ b/src/ui/RichEditor.svelte @@ -1,6 +1,6 @@
    - + picker.click()} {source} ontogglesource={toggleSource} /> + { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
    @@ -142,18 +118,7 @@ .rich { border: 1px solid var(--line); background: var(--bg-0); } .rich:focus-within { border-color: var(--accent); } .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; } - .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.hidden { display: none; } .area :global(p) { margin: 0 0 6px; } diff --git a/src/ui/RightPanel.svelte b/src/ui/RightPanel.svelte index 353bbe4..f3c50b8 100644 --- a/src/ui/RightPanel.svelte +++ b/src/ui/RightPanel.svelte @@ -2,6 +2,7 @@ import Icon from './Icon.svelte'; import Avatar from './Avatar.svelte'; import Members from './Members.svelte'; + import ResizeHandle from './ResizeHandle.svelte'; import ChatLog from './ChatLog.svelte'; import Composer from './Composer.svelte'; import { session } from '../lib/session.svelte.ts'; @@ -33,37 +34,7 @@ const MIN_W = 240, DEFAULT_W = 300; 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 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 draft = $state(''); @@ -81,14 +52,8 @@ - setWidth(width)} /> - -