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

- Channels: create, edit, delete, link and move (drag and drop for channels and
  people), right-click on free space in the channel list
- Permission editor with inherited rules, allow/deny per permission, groups and
  members; menus grey out actions the server does not allow
- Notification sounds for connection, channel, chat, mute and push-to-talk events;
  custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB
- Color schemes: Dark, Light, Midnight, Frost and Windows 95
- Public server list with live UDP ping, fetched through Chromium's network stack
- Kick, ban and connection-lost card with Reconnect
- Right-click menus for text fields, links, images and message authors
- Formatting toolbar in the message box, shared with the description editor
- Resizable channel list, menu headers, registration from the self menu only
- Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly,
  so quick actions are no longer dropped silently

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:54:03 +02:00
co-authored by Claude Opus 5.5
parent aa679dddcc
commit 9a3aeb29ad
53 changed files with 2684 additions and 170 deletions
+32 -2
View File
@@ -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) {