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
+438
View File
@@ -0,0 +1,438 @@
<script lang="ts">
import Modal from './Modal.svelte';
import RichEditor from './RichEditor.svelte';
import { session } from '../lib/session.svelte.ts';
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
| { mode: 'edit'; id: number; tab?: 'general' | 'permissions' | 'groups'; onclose: () => void; parent?: undefined };
let props: Props = $props();
// svelte-ignore state_referenced_locally
const { mode, onclose } = props;
// svelte-ignore state_referenced_locally
const channelId = props.mode === 'edit' ? props.id : null;
// svelte-ignore state_referenced_locally
const parentId = props.mode === 'create' ? props.parent : null;
const client = session.client;
const channel = $derived(channelId != null ? session.channel(channelId) : undefined);
const parent = $derived(session.channel(parentId ?? channel?.parent ?? 0));
const isRoot = channelId === 0;
// svelte-ignore state_referenced_locally
let tab = $state<'general' | 'permissions' | 'groups'>(props.tab ?? 'general');
let error = $state('');
// ─── General ────────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? '');
let description = $state(initial?.description ?? '');
let position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false);
const canTemp = $derived(session.can(parentId ?? 0, PERM.MakeTempChannel) !== false);
const canPermanent = $derived(session.can(parentId ?? 0, PERM.MakeChannel) !== false);
$effect(() => { if (mode === 'create' && !canPermanent && canTemp) temporary = true; });
// Long descriptions arrive later; take them into the editor once they do
const descPending = $derived(!!channel?.descriptionHash && !channel.description);
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId);
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; }
});
// ─── ACL ────────────────────────────────────────────────────────────────────
let acl = $state<ChannelAcl | null>(null);
let aclDirty = $state(false);
let selectedRule = $state(-1);
let selectedGroup = $state('');
let names = $state<Record<number, string>>({});
const offs: (() => void)[] = [];
if (client && channelId != null) {
offs.push(client.on('acl', a => {
if (a.channelId !== channelId || acl) return;
acl = a;
selectedRule = a.acls.findIndex(r => !r.inherited);
selectedGroup = a.groups[0]?.name ?? '';
}));
offs.push(client.on('userNames', m => { names = Object.fromEntries(m); }));
names = Object.fromEntries(client.registeredNames);
client.queryAcl(channelId);
}
$effect(() => () => offs.forEach(off => off()));
const userName = (id: number) => names[id] ?? `user #${id}`;
const touch = () => { aclDirty = true; };
// Registered user id for a name, asked from the server
function resolveName(raw: string): Promise<number | null> {
const wanted = raw.trim().toLowerCase();
const known = Object.entries(names).find(([, n]) => n.toLowerCase() === wanted);
if (known) return Promise.resolve(Number(known[0]));
if (!client || !wanted) return Promise.resolve(null);
return new Promise(resolve => {
const timer = setTimeout(() => { off(); resolve(null); }, 3000);
const off = client.on('userNames', m => {
for (const [id, n] of m) if (n.toLowerCase() === wanted) { clearTimeout(timer); off(); resolve(id); return; }
});
client.queryUsers({ names: [raw.trim()] });
});
}
const PERMS: { bit: number; name: string; rootOnly?: boolean }[] = [
{ bit: PERM.Write, name: 'Write ACL (full control)' },
{ bit: PERM.Traverse, name: 'Traverse' },
{ bit: PERM.Enter, name: 'Enter' },
{ bit: PERM.Speak, name: 'Speak' },
{ bit: PERM.Whisper, name: 'Whisper' },
{ bit: PERM.TextMessage, name: 'Text message' },
{ bit: PERM.Listen, name: 'Listen' },
{ bit: PERM.MuteDeafen, name: 'Mute and deafen' },
{ bit: PERM.Move, name: 'Move users' },
{ bit: PERM.MakeChannel, name: 'Make channel' },
{ bit: PERM.MakeTempChannel, name: 'Make temporary channel' },
{ bit: PERM.LinkChannel, name: 'Link channel' },
{ bit: PERM.Kick, name: 'Kick users', rootOnly: true },
{ bit: PERM.Ban, name: 'Ban users', rootOnly: true },
{ bit: PERM.Register, name: 'Register users', rootOnly: true },
{ bit: PERM.SelfRegister, name: 'Register self', rootOnly: true },
{ bit: PERM.ResetUserContent, name: 'Reset user content', rootOnly: true }
];
const permList = $derived(PERMS.filter(p => !p.rootOnly || isRoot));
const BUILTIN_GROUPS = ['all', 'auth', 'in', 'out', 'sub', 'admin'];
const groupSuggestions = $derived([...new Set([...BUILTIN_GROUPS, ...(acl?.groups.map(g => g.name) ?? [])])]);
// $<certhash> groups match one certificate; name them after the online user when we can
function groupLabel(g: string | null): string {
if (g?.startsWith('$')) {
const u = session.allUsers().find(x => x.hash === g.slice(1));
return u ? `Certificate of ${u.name}` : `Certificate ${g.slice(1, 9)}...`;
}
return `@${g}`;
}
const ruleLabel = (r: AclEntry) => r.userId != null ? userName(r.userId) : groupLabel(r.group);
const rule = $derived(acl && selectedRule >= 0 ? acl.acls[selectedRule] : null);
let targetText = $state('');
let targetKind = $state<'group' | 'user'>('group');
$effect(() => {
const r = rule;
if (!r) return;
targetKind = r.userId != null ? 'user' : 'group';
targetText = r.userId != null ? userName(r.userId) : r.group ?? 'all';
});
function addRule() {
if (!acl) return;
acl.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0 });
selectedRule = acl.acls.length - 1;
touch();
}
function removeRule() {
if (!acl || !rule || rule.inherited) return;
acl.acls.splice(selectedRule, 1);
selectedRule = Math.min(selectedRule, acl.acls.length - 1);
touch();
}
// Rules are evaluated in order; only the channel's own rules can move, and not above inherited ones
function moveRule(dir: -1 | 1) {
if (!acl || !rule || rule.inherited) return;
const to = selectedRule + dir;
if (to < 0 || to >= acl.acls.length || acl.acls[to].inherited) return;
[acl.acls[selectedRule], acl.acls[to]] = [acl.acls[to], acl.acls[selectedRule]];
selectedRule = to;
touch();
}
async function applyTarget() {
if (!rule || rule.inherited) return;
error = '';
if (targetKind === 'group') {
rule.userId = null;
rule.group = targetText.trim().replace(/^@/, '') || 'all';
} else {
const id = await resolveName(targetText);
if (id == null) { error = `"${targetText}" is not a registered user on this server.`; return; }
rule.userId = id;
rule.group = null;
}
touch();
}
function setBit(kind: 'grant' | 'deny', bit: number, on: boolean) {
if (!rule || rule.inherited) return;
const other = kind === 'grant' ? 'deny' : 'grant';
rule[kind] = on ? rule[kind] | bit : rule[kind] & ~bit;
if (on) rule[other] &= ~bit; // allow and deny exclude each other
touch();
}
// ─── Groups ─────────────────────────────────────────────────────────────────
const group = $derived(acl?.groups.find(g => g.name === selectedGroup) ?? null);
let newGroup = $state('');
let newMember = $state('');
function addGroup() {
const n = newGroup.trim().replace(/^@/, '');
if (!acl || !n || acl.groups.some(g => g.name === n)) return;
acl.groups.push({ name: n, inherited: false, inherit: true, inheritable: true, add: [], remove: [], inheritedMembers: [] });
selectedGroup = n;
newGroup = '';
touch();
}
function deleteGroup() {
if (!acl || !group || group.inherited) return;
acl.groups = acl.groups.filter(g => g !== group);
selectedGroup = acl.groups[0]?.name ?? '';
touch();
}
async function addMember() {
if (!group) return;
error = '';
const id = await resolveName(newMember);
if (id == null) { error = `"${newMember}" is not a registered user on this server.`; return; }
if (!group.add.includes(id)) group.add.push(id);
group.remove = group.remove.filter(x => x !== id);
newMember = '';
touch();
}
function toggleInheritedMember(g: AclGroup, id: number) {
g.remove = g.remove.includes(id) ? g.remove.filter(x => x !== id) : [...g.remove, id];
touch();
}
// ─── Save ───────────────────────────────────────────────────────────────────
function save() {
if (!client) return;
error = '';
if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; }
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers });
sounds.play('channelCreated');
return onclose();
}
const ch = channel;
if (!ch || channelId == null) return onclose();
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim();
if (descLoaded && description !== ch.description) fields.description = description;
if (position !== ch.position) fields.position = Number(position) || 0;
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
if (Object.keys(fields).length) client.updateChannel(channelId, fields);
if (acl && aclDirty) client.saveAcl($state.snapshot(acl) as ChannelAcl);
if (Object.keys(fields).length || aclDirty) sounds.play('channelEdited');
onclose();
}
const title = $derived(mode === 'create' ? `New channel in ${parent?.name || 'Root'}` : `Channel: ${channel?.name || 'Root'}`);
</script>
<Modal {title} {onclose} width={760}>
{#if mode === 'edit'}
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'general'} class:active={tab === 'general'} onclick={() => (tab = 'general')}>General</button>
<button role="tab" aria-selected={tab === 'permissions'} class:active={tab === 'permissions'} onclick={() => (tab = 'permissions')}>Permissions</button>
<button role="tab" aria-selected={tab === 'groups'} class:active={tab === 'groups'} onclick={() => (tab = 'groups')}>Groups</button>
</div>
{/if}
{#if tab === 'general'}
<label for="ch-name">Name</label>
<input id="ch-name" bind:value={name} disabled={isRoot} placeholder="Channel name" />
<label for="ch-desc">Description</label>
{#if descLoaded}
<RichEditor value={description} limit={session.htmlLimit} placeholder="What is this channel for?" onchange={h => (description = h)} />
{:else}
<p class="dim">Loading description...</p>
{/if}
<div class="row3">
<div>
<label for="ch-pos">Position</label>
<input id="ch-pos" type="number" bind:value={position} />
</div>
<div>
<label for="ch-max">User limit</label>
<input id="ch-max" type="number" min="0" bind:value={maxUsers} />
</div>
{#if mode === 'create'}
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if}
</div>
<p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
{:else if !acl}
<p class="dim">Loading permissions...</p>
{:else if tab === 'permissions'}
<label class="check top"><input type="checkbox" bind:checked={acl.inheritAcls} onchange={touch} /> Inherit rules from the parent channel</label>
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Rules">
{#each acl.acls as r, i (i)}
<li>
<button role="option" aria-selected={i === selectedRule} class:sel={i === selectedRule} class:inh={r.inherited} onclick={() => (selectedRule = i)}>
<span>{ruleLabel(r)}</span>
{#if r.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No rules yet</li>
{/each}
</ul>
<div class="listbtns">
<button class="btn" onclick={addRule}>Add</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={removeRule}>Remove</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(-1)} aria-label="Move up">Up</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(1)} aria-label="Move down">Down</button>
</div>
</div>
<div class="detail">
{#if rule}
{#if rule.inherited}<p class="note">Inherited from a parent channel. Edit it there, or turn off inheriting above.</p>{/if}
<fieldset disabled={rule.inherited}>
<div class="target">
<select bind:value={targetKind} aria-label="Target type">
<option value="group">Group</option>
<option value="user">User</option>
</select>
<input bind:value={targetText} list="ch-groups" aria-label="Target" onchange={applyTarget}
placeholder={targetKind === 'group' ? 'all, auth, in, admin...' : 'Registered user name'} />
<datalist id="ch-groups">{#each groupSuggestions as g (g)}<option value={g}></option>{/each}</datalist>
</div>
<div class="applies">
<label class="check"><input type="checkbox" bind:checked={rule.applyHere} onchange={touch} /> Applies to this channel</label>
<label class="check"><input type="checkbox" bind:checked={rule.applySubs} onchange={touch} /> Applies to subchannels</label>
</div>
<table class="perms">
<thead><tr><th>Permission</th><th>Allow</th><th>Deny</th></tr></thead>
<tbody>
{#each permList as p (p.bit)}
<tr>
<td>{p.name}</td>
<td><input type="checkbox" aria-label="Allow {p.name}" checked={(rule.grant & p.bit) !== 0} onchange={e => setBit('grant', p.bit, e.currentTarget.checked)} /></td>
<td><input type="checkbox" aria-label="Deny {p.name}" checked={(rule.deny & p.bit) !== 0} onchange={e => setBit('deny', p.bit, e.currentTarget.checked)} /></td>
</tr>
{/each}
</tbody>
</table>
</fieldset>
{:else}
<p class="dim">Select a rule, or add one.</p>
{/if}
</div>
</div>
{:else}
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Groups">
{#each acl.groups as g (g.name)}
<li>
<button role="option" aria-selected={g.name === selectedGroup} class:sel={g.name === selectedGroup} onclick={() => (selectedGroup = g.name)}>
<span>@{g.name}</span>
{#if g.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No groups yet</li>
{/each}
</ul>
<div class="listbtns">
<input bind:value={newGroup} placeholder="New group" aria-label="New group name" onkeydown={e => { if (e.key === 'Enter') addGroup(); }} />
<button class="btn" onclick={addGroup} disabled={!newGroup.trim()}>Add</button>
<button class="btn" disabled={!group || group.inherited} onclick={deleteGroup}>Delete</button>
</div>
</div>
<div class="detail">
{#if group}
<label class="check"><input type="checkbox" bind:checked={group.inherit} onchange={touch} /> Include members of the parent's @{group.name}</label>
<label class="check"><input type="checkbox" bind:checked={group.inheritable} onchange={touch} /> Subchannels can use this group</label>
<h4>Members added here</h4>
<ul class="members">
{#each group.add as id (id)}
<li><span>{userName(id)}</span><button class="link danger" onclick={() => { group.add = group.add.filter(x => x !== id); touch(); }}>Remove</button></li>
{:else}
<li class="dim small">Nobody yet</li>
{/each}
</ul>
<div class="target">
<input bind:value={newMember} placeholder="Registered user name" aria-label="Member name" onkeydown={e => { if (e.key === 'Enter') addMember(); }} />
<button class="btn" onclick={addMember} disabled={!newMember.trim()}>Add member</button>
</div>
{#if group.inheritedMembers.length}
<h4>Inherited members</h4>
<ul class="members">
{#each group.inheritedMembers as id (id)}
{@const excluded = group.remove.includes(id)}
<li><span class:struck={excluded}>{userName(id)}</span>
<button class="link" onclick={() => toggleInheritedMember(group, id)}>{excluded ? 'Include again' : 'Exclude here'}</button></li>
{/each}
</ul>
{/if}
{:else}
<p class="dim">Select a group, or add one.</p>
{/if}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" onclick={save}>{mode === 'create' ? 'Create channel' : 'Save'}</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.row3 { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.row3 > div { width: 140px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin: 10px 0 0; }
.check input { width: auto; }
.check.top { margin-top: 12px; }
.split { display: grid; grid-template-columns: 220px 1fr; gap: 14px; margin-top: 12px; }
@media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
.list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); background: var(--bg-0); max-height: 340px; overflow-y: auto; }
.list button { display: flex; justify-content: space-between; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; text-align: left; font-size: 14px; }
.list button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list { overflow-x: hidden; }
.list button:hover { background: var(--bg-hover); }
.list button.sel { background: var(--accent); color: var(--accent-text); }
.list button.inh:not(.sel) { color: var(--text-dim); }
.list small { font-size: 11px; opacity: 0.8; }
.pad { padding: 8px 10px; }
.listbtns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.listbtns .btn { padding: 4px 10px; font-size: 13px; }
.listbtns input { flex: 1 1 100%; }
.detail { min-width: 0; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
fieldset:disabled { opacity: 0.6; }
.target { display: flex; gap: 6px; }
.target select { width: 100px; flex: none; }
.applies { display: flex; gap: 16px; flex-wrap: wrap; }
.perms { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
.perms th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.perms td { padding: 3px 6px; border-bottom: 1px solid var(--line); }
.perms th:not(:first-child), .perms td:not(:first-child) { text-align: center; width: 60px; }
.perms input { width: auto; }
h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.members { list-style: none; margin: 0 0 8px; padding: 0; }
.members li { display: flex; justify-content: space-between; padding: 3px 0; font-size: 14px; }
.struck { text-decoration: line-through; color: var(--text-faint); }
.link { color: var(--link); font-size: 13px; }
.link.danger { color: var(--danger); }
.note { margin: 0 0 8px; font-size: 13px; color: var(--warn); }
.dim { color: var(--text-dim); }
.small { font-size: 12px; }
.error { color: var(--danger); margin: 10px 0 0; }
</style>