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
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { store, THEMES, type ThemeId } from '../lib/settings.svelte.ts';
function pick(id: ThemeId) {
store.settings.theme = id;
store.saveSettings();
}
</script>
<h3>Color scheme</h3>
<div class="themes" role="radiogroup" aria-label="Color scheme">
{#each THEMES as t (t.id)}
<button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id}
onclick={() => pick(t.id)} aria-label={t.name}>
<!-- A miniature of the app in that theme -->
<span class="mini" data-theme-preview={t.id} aria-hidden="true">
<span class="m-rail"><span></span><span></span></span>
<span class="m-side"><i></i><i class="sel"></i><i></i></span>
<span class="m-chat">
<span class="m-head"></span>
<i class="a"></i><i></i><i class="short"></i>
<span class="m-box"></span>
</span>
</span>
<span class="name">{t.name}</span>
<span class="note">{t.note}</span>
</button>
{/each}
</div>
<style>
h3 { margin: 8px 0 0; font-size: 14px; }
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
.themes > button.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.name { font-weight: 600; margin-top: 6px; }
.note { font-size: 12px; color: var(--text-dim); }
.mini { display: flex; height: 84px; overflow: hidden; border: 1px solid var(--line); font-family: var(--font); }
.m-rail { width: 16%; background: var(--rail, var(--bg-0)); display: flex; flex-direction: column; align-items: center; gap: 4px; padding-top: 5px; }
.m-rail span { width: 60%; aspect-ratio: 1; background: var(--accent); }
.m-rail span + span { background: var(--bg-3); }
.m-side { width: 30%; background: var(--bg-1); display: flex; flex-direction: column; gap: 5px; padding: 8px 5px; }
.m-side i { height: 5px; background: var(--text-faint); width: 70%; }
.m-side i.sel { background: var(--accent); width: 85%; }
.m-chat { flex: 1; background: var(--bg-2); display: flex; flex-direction: column; gap: 5px; padding: 0 6px 6px; }
.m-head { height: 10px; margin: 0 -6px 2px; background: var(--bg-1); border-bottom: 1px solid var(--line); }
[data-theme-preview="win95"] .m-head { background: linear-gradient(90deg, #000080, #1084d0); }
.m-chat i { height: 4px; background: var(--text-dim); width: 80%; }
.m-chat i.a { background: var(--accent); width: 35%; }
.m-chat i.short { width: 55%; }
.m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); }
</style>
+438
View File
@@ -0,0 +1,438 @@
<script lang="ts">
import Modal from './Modal.svelte';
import RichEditor from './RichEditor.svelte';
import { session } from '../lib/session.svelte.ts';
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
import { sounds } from '../lib/audio/sounds.svelte.ts';
type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
| { mode: 'edit'; id: number; tab?: 'general' | 'permissions' | 'groups'; onclose: () => void; parent?: undefined };
let props: Props = $props();
// svelte-ignore state_referenced_locally
const { mode, onclose } = props;
// svelte-ignore state_referenced_locally
const channelId = props.mode === 'edit' ? props.id : null;
// svelte-ignore state_referenced_locally
const parentId = props.mode === 'create' ? props.parent : null;
const client = session.client;
const channel = $derived(channelId != null ? session.channel(channelId) : undefined);
const parent = $derived(session.channel(parentId ?? channel?.parent ?? 0));
const isRoot = channelId === 0;
// svelte-ignore state_referenced_locally
let tab = $state<'general' | 'permissions' | 'groups'>(props.tab ?? 'general');
let error = $state('');
// ─── General ────────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? '');
let description = $state(initial?.description ?? '');
let position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false);
const canTemp = $derived(session.can(parentId ?? 0, PERM.MakeTempChannel) !== false);
const canPermanent = $derived(session.can(parentId ?? 0, PERM.MakeChannel) !== false);
$effect(() => { if (mode === 'create' && !canPermanent && canTemp) temporary = true; });
// Long descriptions arrive later; take them into the editor once they do
const descPending = $derived(!!channel?.descriptionHash && !channel.description);
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId);
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; }
});
// ─── ACL ────────────────────────────────────────────────────────────────────
let acl = $state<ChannelAcl | null>(null);
let aclDirty = $state(false);
let selectedRule = $state(-1);
let selectedGroup = $state('');
let names = $state<Record<number, string>>({});
const offs: (() => void)[] = [];
if (client && channelId != null) {
offs.push(client.on('acl', a => {
if (a.channelId !== channelId || acl) return;
acl = a;
selectedRule = a.acls.findIndex(r => !r.inherited);
selectedGroup = a.groups[0]?.name ?? '';
}));
offs.push(client.on('userNames', m => { names = Object.fromEntries(m); }));
names = Object.fromEntries(client.registeredNames);
client.queryAcl(channelId);
}
$effect(() => () => offs.forEach(off => off()));
const userName = (id: number) => names[id] ?? `user #${id}`;
const touch = () => { aclDirty = true; };
// Registered user id for a name, asked from the server
function resolveName(raw: string): Promise<number | null> {
const wanted = raw.trim().toLowerCase();
const known = Object.entries(names).find(([, n]) => n.toLowerCase() === wanted);
if (known) return Promise.resolve(Number(known[0]));
if (!client || !wanted) return Promise.resolve(null);
return new Promise(resolve => {
const timer = setTimeout(() => { off(); resolve(null); }, 3000);
const off = client.on('userNames', m => {
for (const [id, n] of m) if (n.toLowerCase() === wanted) { clearTimeout(timer); off(); resolve(id); return; }
});
client.queryUsers({ names: [raw.trim()] });
});
}
const PERMS: { bit: number; name: string; rootOnly?: boolean }[] = [
{ bit: PERM.Write, name: 'Write ACL (full control)' },
{ bit: PERM.Traverse, name: 'Traverse' },
{ bit: PERM.Enter, name: 'Enter' },
{ bit: PERM.Speak, name: 'Speak' },
{ bit: PERM.Whisper, name: 'Whisper' },
{ bit: PERM.TextMessage, name: 'Text message' },
{ bit: PERM.Listen, name: 'Listen' },
{ bit: PERM.MuteDeafen, name: 'Mute and deafen' },
{ bit: PERM.Move, name: 'Move users' },
{ bit: PERM.MakeChannel, name: 'Make channel' },
{ bit: PERM.MakeTempChannel, name: 'Make temporary channel' },
{ bit: PERM.LinkChannel, name: 'Link channel' },
{ bit: PERM.Kick, name: 'Kick users', rootOnly: true },
{ bit: PERM.Ban, name: 'Ban users', rootOnly: true },
{ bit: PERM.Register, name: 'Register users', rootOnly: true },
{ bit: PERM.SelfRegister, name: 'Register self', rootOnly: true },
{ bit: PERM.ResetUserContent, name: 'Reset user content', rootOnly: true }
];
const permList = $derived(PERMS.filter(p => !p.rootOnly || isRoot));
const BUILTIN_GROUPS = ['all', 'auth', 'in', 'out', 'sub', 'admin'];
const groupSuggestions = $derived([...new Set([...BUILTIN_GROUPS, ...(acl?.groups.map(g => g.name) ?? [])])]);
// $<certhash> groups match one certificate; name them after the online user when we can
function groupLabel(g: string | null): string {
if (g?.startsWith('$')) {
const u = session.allUsers().find(x => x.hash === g.slice(1));
return u ? `Certificate of ${u.name}` : `Certificate ${g.slice(1, 9)}...`;
}
return `@${g}`;
}
const ruleLabel = (r: AclEntry) => r.userId != null ? userName(r.userId) : groupLabel(r.group);
const rule = $derived(acl && selectedRule >= 0 ? acl.acls[selectedRule] : null);
let targetText = $state('');
let targetKind = $state<'group' | 'user'>('group');
$effect(() => {
const r = rule;
if (!r) return;
targetKind = r.userId != null ? 'user' : 'group';
targetText = r.userId != null ? userName(r.userId) : r.group ?? 'all';
});
function addRule() {
if (!acl) return;
acl.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0 });
selectedRule = acl.acls.length - 1;
touch();
}
function removeRule() {
if (!acl || !rule || rule.inherited) return;
acl.acls.splice(selectedRule, 1);
selectedRule = Math.min(selectedRule, acl.acls.length - 1);
touch();
}
// Rules are evaluated in order; only the channel's own rules can move, and not above inherited ones
function moveRule(dir: -1 | 1) {
if (!acl || !rule || rule.inherited) return;
const to = selectedRule + dir;
if (to < 0 || to >= acl.acls.length || acl.acls[to].inherited) return;
[acl.acls[selectedRule], acl.acls[to]] = [acl.acls[to], acl.acls[selectedRule]];
selectedRule = to;
touch();
}
async function applyTarget() {
if (!rule || rule.inherited) return;
error = '';
if (targetKind === 'group') {
rule.userId = null;
rule.group = targetText.trim().replace(/^@/, '') || 'all';
} else {
const id = await resolveName(targetText);
if (id == null) { error = `"${targetText}" is not a registered user on this server.`; return; }
rule.userId = id;
rule.group = null;
}
touch();
}
function setBit(kind: 'grant' | 'deny', bit: number, on: boolean) {
if (!rule || rule.inherited) return;
const other = kind === 'grant' ? 'deny' : 'grant';
rule[kind] = on ? rule[kind] | bit : rule[kind] & ~bit;
if (on) rule[other] &= ~bit; // allow and deny exclude each other
touch();
}
// ─── Groups ─────────────────────────────────────────────────────────────────
const group = $derived(acl?.groups.find(g => g.name === selectedGroup) ?? null);
let newGroup = $state('');
let newMember = $state('');
function addGroup() {
const n = newGroup.trim().replace(/^@/, '');
if (!acl || !n || acl.groups.some(g => g.name === n)) return;
acl.groups.push({ name: n, inherited: false, inherit: true, inheritable: true, add: [], remove: [], inheritedMembers: [] });
selectedGroup = n;
newGroup = '';
touch();
}
function deleteGroup() {
if (!acl || !group || group.inherited) return;
acl.groups = acl.groups.filter(g => g !== group);
selectedGroup = acl.groups[0]?.name ?? '';
touch();
}
async function addMember() {
if (!group) return;
error = '';
const id = await resolveName(newMember);
if (id == null) { error = `"${newMember}" is not a registered user on this server.`; return; }
if (!group.add.includes(id)) group.add.push(id);
group.remove = group.remove.filter(x => x !== id);
newMember = '';
touch();
}
function toggleInheritedMember(g: AclGroup, id: number) {
g.remove = g.remove.includes(id) ? g.remove.filter(x => x !== id) : [...g.remove, id];
touch();
}
// ─── Save ───────────────────────────────────────────────────────────────────
function save() {
if (!client) return;
error = '';
if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; }
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers });
sounds.play('channelCreated');
return onclose();
}
const ch = channel;
if (!ch || channelId == null) return onclose();
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim();
if (descLoaded && description !== ch.description) fields.description = description;
if (position !== ch.position) fields.position = Number(position) || 0;
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
if (Object.keys(fields).length) client.updateChannel(channelId, fields);
if (acl && aclDirty) client.saveAcl($state.snapshot(acl) as ChannelAcl);
if (Object.keys(fields).length || aclDirty) sounds.play('channelEdited');
onclose();
}
const title = $derived(mode === 'create' ? `New channel in ${parent?.name || 'Root'}` : `Channel: ${channel?.name || 'Root'}`);
</script>
<Modal {title} {onclose} width={760}>
{#if mode === 'edit'}
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'general'} class:active={tab === 'general'} onclick={() => (tab = 'general')}>General</button>
<button role="tab" aria-selected={tab === 'permissions'} class:active={tab === 'permissions'} onclick={() => (tab = 'permissions')}>Permissions</button>
<button role="tab" aria-selected={tab === 'groups'} class:active={tab === 'groups'} onclick={() => (tab = 'groups')}>Groups</button>
</div>
{/if}
{#if tab === 'general'}
<label for="ch-name">Name</label>
<input id="ch-name" bind:value={name} disabled={isRoot} placeholder="Channel name" />
<label for="ch-desc">Description</label>
{#if descLoaded}
<RichEditor value={description} limit={session.htmlLimit} placeholder="What is this channel for?" onchange={h => (description = h)} />
{:else}
<p class="dim">Loading description...</p>
{/if}
<div class="row3">
<div>
<label for="ch-pos">Position</label>
<input id="ch-pos" type="number" bind:value={position} />
</div>
<div>
<label for="ch-max">User limit</label>
<input id="ch-max" type="number" min="0" bind:value={maxUsers} />
</div>
{#if mode === 'create'}
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if}
</div>
<p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
{:else if !acl}
<p class="dim">Loading permissions...</p>
{:else if tab === 'permissions'}
<label class="check top"><input type="checkbox" bind:checked={acl.inheritAcls} onchange={touch} /> Inherit rules from the parent channel</label>
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Rules">
{#each acl.acls as r, i (i)}
<li>
<button role="option" aria-selected={i === selectedRule} class:sel={i === selectedRule} class:inh={r.inherited} onclick={() => (selectedRule = i)}>
<span>{ruleLabel(r)}</span>
{#if r.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No rules yet</li>
{/each}
</ul>
<div class="listbtns">
<button class="btn" onclick={addRule}>Add</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={removeRule}>Remove</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(-1)} aria-label="Move up">Up</button>
<button class="btn" disabled={!rule || rule.inherited} onclick={() => moveRule(1)} aria-label="Move down">Down</button>
</div>
</div>
<div class="detail">
{#if rule}
{#if rule.inherited}<p class="note">Inherited from a parent channel. Edit it there, or turn off inheriting above.</p>{/if}
<fieldset disabled={rule.inherited}>
<div class="target">
<select bind:value={targetKind} aria-label="Target type">
<option value="group">Group</option>
<option value="user">User</option>
</select>
<input bind:value={targetText} list="ch-groups" aria-label="Target" onchange={applyTarget}
placeholder={targetKind === 'group' ? 'all, auth, in, admin...' : 'Registered user name'} />
<datalist id="ch-groups">{#each groupSuggestions as g (g)}<option value={g}></option>{/each}</datalist>
</div>
<div class="applies">
<label class="check"><input type="checkbox" bind:checked={rule.applyHere} onchange={touch} /> Applies to this channel</label>
<label class="check"><input type="checkbox" bind:checked={rule.applySubs} onchange={touch} /> Applies to subchannels</label>
</div>
<table class="perms">
<thead><tr><th>Permission</th><th>Allow</th><th>Deny</th></tr></thead>
<tbody>
{#each permList as p (p.bit)}
<tr>
<td>{p.name}</td>
<td><input type="checkbox" aria-label="Allow {p.name}" checked={(rule.grant & p.bit) !== 0} onchange={e => setBit('grant', p.bit, e.currentTarget.checked)} /></td>
<td><input type="checkbox" aria-label="Deny {p.name}" checked={(rule.deny & p.bit) !== 0} onchange={e => setBit('deny', p.bit, e.currentTarget.checked)} /></td>
</tr>
{/each}
</tbody>
</table>
</fieldset>
{:else}
<p class="dim">Select a rule, or add one.</p>
{/if}
</div>
</div>
{:else}
<div class="split">
<div class="listcol">
<ul class="list" role="listbox" aria-label="Groups">
{#each acl.groups as g (g.name)}
<li>
<button role="option" aria-selected={g.name === selectedGroup} class:sel={g.name === selectedGroup} onclick={() => (selectedGroup = g.name)}>
<span>@{g.name}</span>
{#if g.inherited}<small>inherited</small>{/if}
</button>
</li>
{:else}
<li class="dim small pad">No groups yet</li>
{/each}
</ul>
<div class="listbtns">
<input bind:value={newGroup} placeholder="New group" aria-label="New group name" onkeydown={e => { if (e.key === 'Enter') addGroup(); }} />
<button class="btn" onclick={addGroup} disabled={!newGroup.trim()}>Add</button>
<button class="btn" disabled={!group || group.inherited} onclick={deleteGroup}>Delete</button>
</div>
</div>
<div class="detail">
{#if group}
<label class="check"><input type="checkbox" bind:checked={group.inherit} onchange={touch} /> Include members of the parent's @{group.name}</label>
<label class="check"><input type="checkbox" bind:checked={group.inheritable} onchange={touch} /> Subchannels can use this group</label>
<h4>Members added here</h4>
<ul class="members">
{#each group.add as id (id)}
<li><span>{userName(id)}</span><button class="link danger" onclick={() => { group.add = group.add.filter(x => x !== id); touch(); }}>Remove</button></li>
{:else}
<li class="dim small">Nobody yet</li>
{/each}
</ul>
<div class="target">
<input bind:value={newMember} placeholder="Registered user name" aria-label="Member name" onkeydown={e => { if (e.key === 'Enter') addMember(); }} />
<button class="btn" onclick={addMember} disabled={!newMember.trim()}>Add member</button>
</div>
{#if group.inheritedMembers.length}
<h4>Inherited members</h4>
<ul class="members">
{#each group.inheritedMembers as id (id)}
{@const excluded = group.remove.includes(id)}
<li><span class:struck={excluded}>{userName(id)}</span>
<button class="link" onclick={() => toggleInheritedMember(group, id)}>{excluded ? 'Include again' : 'Exclude here'}</button></li>
{/each}
</ul>
{/if}
{:else}
<p class="dim">Select a group, or add one.</p>
{/if}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#snippet footer()}
<button class="btn" onclick={onclose}>Cancel</button>
<button class="btn primary" onclick={save}>{mode === 'create' ? 'Create channel' : 'Save'}</button>
{/snippet}
</Modal>
<style>
.tabs { display: flex; gap: 2px; margin: 8px 0 4px; border-bottom: 1px solid var(--line); }
.tabs button { padding: 8px 14px; color: var(--text-dim); font-weight: 600; }
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
.row3 { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
.row3 > div { width: 140px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin: 10px 0 0; }
.check input { width: auto; }
.check.top { margin-top: 12px; }
.split { display: grid; grid-template-columns: 220px 1fr; gap: 14px; margin-top: 12px; }
@media (max-width: 640px) { .split { grid-template-columns: 1fr; } }
.list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); background: var(--bg-0); max-height: 340px; overflow-y: auto; }
.list button { display: flex; justify-content: space-between; align-items: center; gap: 8px; width: 100%; padding: 6px 10px; text-align: left; font-size: 14px; }
.list button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list { overflow-x: hidden; }
.list button:hover { background: var(--bg-hover); }
.list button.sel { background: var(--accent); color: var(--accent-text); }
.list button.inh:not(.sel) { color: var(--text-dim); }
.list small { font-size: 11px; opacity: 0.8; }
.pad { padding: 8px 10px; }
.listbtns { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.listbtns .btn { padding: 4px 10px; font-size: 13px; }
.listbtns input { flex: 1 1 100%; }
.detail { min-width: 0; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
fieldset:disabled { opacity: 0.6; }
.target { display: flex; gap: 6px; }
.target select { width: 100px; flex: none; }
.applies { display: flex; gap: 16px; flex-wrap: wrap; }
.perms { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }
.perms th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.perms td { padding: 3px 6px; border-bottom: 1px solid var(--line); }
.perms th:not(:first-child), .perms td:not(:first-child) { text-align: center; width: 60px; }
.perms input { width: auto; }
h4 { margin: 16px 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.members { list-style: none; margin: 0 0 8px; padding: 0; }
.members li { display: flex; justify-content: space-between; padding: 3px 0; font-size: 14px; }
.struck { text-decoration: line-through; color: var(--text-faint); }
.link { color: var(--link); font-size: 13px; }
.link.danger { color: var(--danger); }
.note { margin: 0 0 8px; font-size: 13px; color: var(--warn); }
.dim { color: var(--text-dim); }
.small { font-size: 12px; }
.error { color: var(--danger); margin: 10px 0 0; }
</style>
+43 -3
View File
@@ -8,6 +8,8 @@
import { channelMenu, userMenu } from '../lib/actions.ts';
import { 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 @@
</script>
<li>
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
<div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px"
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }}
ondragleave={() => (over = false)}
ondrop={onDrop}>
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
@@ -47,7 +78,11 @@
{:else}
<span class="caret"></span>
{/if}
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element -->
<button class="name" title="Click for details, double-click to join" {...channelClicks}
draggable={channel.id !== 0}
ondragstart={e => { drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }}
ondragend={() => { drag.item = null; over = false; }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span>
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
@@ -65,6 +100,9 @@
)}
<li>
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
draggable="true"
ondragstart={e => { drag.item = { kind: 'user', id: u.session }; e.dataTransfer?.setData('text/plain', u.name); }}
ondragend={() => (drag.item = null)}
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
@@ -98,6 +136,8 @@
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); }
.channel.over { background: var(--accent); color: var(--accent-text); }
.channel.over .label { color: var(--accent-text); }
.channel.here .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
+28 -1
View File
@@ -3,6 +3,7 @@
import ChatLog from './ChatLog.svelte';
import Composer from './Composer.svelte';
import VoiceStage from './VoiceStage.svelte';
import { ui } from '../lib/ui.svelte.ts';
import { session } from '../lib/session.svelte.ts';
// showStage: voice tiles above the chat when the window is too narrow for the member list
@@ -28,6 +29,24 @@
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
{#if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
<div class="ended" class:bad={d.kind === 'kicked' || d.kind === 'banned'}>
<h2>
{#if d.kind === 'kicked'}You were kicked{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'banned'}You were banned{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'rejected'}The server refused the connection
{:else}Connection lost{/if}
</h2>
{#if d.reason}<p class="reason">{d.reason}</p>{/if}
<p class="where">{session.server?.label}</p>
<div class="row">
<button class="btn primary" onclick={() => session.reconnect()}>Reconnect</button>
</div>
</div>
{/if}
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
@@ -38,7 +57,8 @@
<h2>Connecting...</h2>
{:else}
<h2>mumh5</h2>
<p>Choose a server to get started.</p>
<p>Choose a server to get started, or find one:</p>
<button class="btn browse" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
{/if}
</div>
{/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; }
</style>
+115 -26
View File
@@ -1,48 +1,112 @@
<script lang="ts">
import Icon from './Icon.svelte';
import FormatToolbar from './FormatToolbar.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { acceptAttr } from '../lib/upload.ts';
import { toMumbleHtml } from '../lib/html.ts';
// target: the chat this composer sends to ("ch:<id>" or "dm:<session>")
let { target, placeholder, autofocus = true }: { target: string; placeholder: string; autofocus?: boolean } = $props();
let text = $state('');
let input: HTMLTextAreaElement;
let input: HTMLDivElement;
let picker: HTMLInputElement;
let imagePicker: HTMLInputElement;
let empty = $state(true);
let source = $state(false);
let sourceText = $state('');
// The formatting toolbar is opt-in and remembered
let formatting = $state(loadFormatting());
const disabled = $derived(session.status !== 'connected');
const uploads = $derived(session.uploads.filter(u => u.key === target));
function loadFormatting(): boolean {
try { return localStorage.getItem('mumh5.formatBar') === '1'; } catch { return false; }
}
function toggleFormatting() {
formatting = !formatting;
try { localStorage.setItem('mumh5.formatBar', formatting ? '1' : '0'); } catch { /* storage unavailable */ }
if (!formatting && source) toggleSource();
input.focus();
}
// Know the host's allowed types before the picker opens
$effect(() => {
if (store.settings.uploadHost && store.settings.uploadKey) session.loadUploadInfo().catch(() => {});
});
const sync = () => { empty = !input.textContent?.trim() && !input.querySelector('img'); };
function exec(cmd: string, arg?: string) {
input.focus();
document.execCommand(cmd, false, arg);
sync();
}
function submit() {
if (session.sendText(text, target)) text = '';
const html = source ? sourceText : input.innerHTML;
// Plain text goes out the usual way; anything formatted as sanitized XHTML
const formatted = source || !!input.querySelector('b, strong, i, em, u, s, strike, span[style], font, ul, ol, a, img');
const ok = formatted
? session.sendHtml(toMumbleHtml(html, { linkify: true }), target)
: session.sendText(input.innerText, target);
if (!ok) return;
input.innerHTML = '';
sourceText = '';
if (source) source = false;
sync();
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
submit();
if (e.shiftKey) exec('insertLineBreak');
else submit();
return;
}
if (e.ctrlKey || e.metaKey) {
const cmd = ({ b: 'bold', i: 'italic', u: 'underline' } as Record<string, string>)[e.key.toLowerCase()];
if (cmd) { e.preventDefault(); exec(cmd); }
}
}
function onpaste(e: ClipboardEvent) {
const files = [...(e.clipboardData?.files ?? [])];
if (files.length) {
e.preventDefault();
session.uploadFiles(files, target);
}
e.preventDefault();
if (files.length) return session.uploadFiles(files, target);
const html = e.clipboardData?.getData('text/html');
if (html && formatting) exec('insertHTML', toMumbleHtml(html));
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
}
// Grow with content up to a limit
$effect(() => {
void text;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
});
// window.prompt does not exist in Electron; the app's dialog, then restore the selection
function link() {
const sel = window.getSelection();
const range = sel && sel.rangeCount && input.contains(sel.anchorNode) ? sel.getRangeAt(0).cloneRange() : null;
ui.prompt = {
title: 'Insert link', label: 'Address (https://...)', confirm: 'Insert link', initial: 'https://',
onsubmit: raw => {
const url = raw.trim();
if (!/^https?:\/\/\S+$/i.test(url)) return;
input.focus();
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
if (range && !range.collapsed) exec('createLink', url);
else exec('insertHTML', `<a href="${url.replace(/"/g, '&quot;')}">${url.replace(/</g, '&lt;')}</a>`);
}
};
}
function toggleSource() {
if (!source) {
sourceText = toMumbleHtml(input.innerHTML);
source = true;
} else {
input.innerHTML = toMumbleHtml(sourceText);
source = false;
sync();
}
}
$effect(() => {
void target;
@@ -62,21 +126,46 @@
{/each}
</ul>
{/if}
<div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? "Upload a file" : "Send an image"} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<textarea bind:this={input} bind:value={text} rows="1" {placeholder} {disabled} {onkeydown} {onpaste}
aria-label="Message"></textarea>
<button class="icon-btn send" title="Send" disabled={disabled || !text.trim()} onclick={submit}><Icon name="send" size={18} /></button>
<div class="frame">
{#if formatting}
<FormatToolbar {exec} onlink={link} onimage={() => imagePicker.click()} {source} ontogglesource={toggleSource} />
{/if}
<div class="box">
<button class="icon-btn" title={store.settings.uploadHost ? 'Upload a file' : 'Send an image'} {disabled} onclick={() => picker.click()}><Icon name="paperclip" /></button>
<input bind:this={picker} type="file" multiple hidden
accept={store.settings.uploadHost && store.settings.uploadKey ? acceptAttr(session.uploadInfo) : 'image/*'}
onchange={() => { session.uploadFiles([...(picker.files ?? [])], target); picker.value = ''; }} />
<input bind:this={imagePicker} type="file" accept="image/*" hidden
onchange={() => { session.uploadFiles([...(imagePicker.files ?? [])], target); imagePicker.value = ''; }} />
<div class="input" class:blank={empty} class:hidden={source} bind:this={input} contenteditable={!disabled}
role="textbox" aria-multiline="true" aria-label="Message" tabindex="0" data-placeholder={placeholder}
oninput={sync} {onkeydown} {onpaste}></div>
{#if source}
<textarea class="input src" bind:value={sourceText} aria-label="HTML source" spellcheck="false"
onkeydown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }}></textarea>
{/if}
<button class="icon-btn fmt" class:on={formatting} title="Text formatting" aria-label="Text formatting" aria-pressed={formatting}
{disabled} onmousedown={e => e.preventDefault()} onclick={toggleFormatting}>Aa</button>
<button class="icon-btn send" title="Send" disabled={disabled || (empty && !(source && sourceText.trim()))} onclick={submit}><Icon name="send" size={18} /></button>
</div>
</div>
</div>
<style>
.composer { flex: none; padding: 0 16px 16px; }
.box { display: flex; align-items: flex-end; gap: 4px; background: var(--bg-3); padding: 6px; }
textarea { flex: 1; resize: none; border: 0; background: transparent; padding: 6px 4px; max-height: 200px; line-height: 1.4; }
/* Same height as the user panel at the bottom of the sidebar; grows for longer messages */
.composer { flex: none; min-height: var(--footer-h); display: flex; flex-direction: column; justify-content: flex-end; padding: 6px 16px; background: var(--bg-0); }
.frame { background: var(--bg-3); }
.frame :global(.toolbar) { background: var(--bg-2); }
.box { display: flex; align-items: flex-end; gap: 4px; min-height: calc(var(--footer-h) - 12px); padding: 2px 4px; }
.input { position: relative; flex: 1; min-width: 0; padding: 8px 4px; max-height: 200px; overflow-y: auto; line-height: 1.4; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
.input.hidden { display: none; }
.input.blank::before { content: attr(data-placeholder); position: absolute; left: 4px; top: 8px; color: var(--text-faint); pointer-events: none; }
.input :global(img) { max-height: 120px; }
.input :global(ul), .input :global(ol) { margin: 0; padding-left: 20px; }
.input[contenteditable='false'] { color: var(--text-faint); }
.src { border: 0; resize: none; background: transparent; font: 12px var(--mono); min-height: 60px; }
.fmt { font-weight: 700; font-size: 13px; }
.fmt.on { color: var(--accent-hover); }
.send:not(:disabled) { color: var(--accent-hover); }
.uploads { list-style: none; margin: 0 0 6px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.uploads li { display: flex; align-items: center; gap: 8px; background: var(--bg-1); border: 1px solid var(--line); padding: 6px 10px; font-size: 13px; }
+9 -5
View File
@@ -5,14 +5,18 @@
import { identities } from '../lib/identities.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { server, onclose }: { server: SavedServer | null; onclose: () => void } = $props();
// prefill: values for a new server, e.g. picked from the public list
let { server, prefill, onclose }: { server: SavedServer | null; prefill?: { host?: string; port?: number; label?: string }; onclose: () => void } = $props();
// svelte-ignore state_referenced_locally
const initial = server;
let label = $state(initial?.label ?? '');
let host = $state(initial?.host ?? '');
let port = $state(initial?.port ?? 64738);
let username = $state(initial?.username ?? '');
// svelte-ignore state_referenced_locally
const pre = prefill ?? {};
let label = $state(initial?.label ?? pre.label ?? '');
let host = $state(initial?.host ?? pre.host ?? '');
let port = $state(initial?.port ?? pre.port ?? 64738);
// New servers start with the name you used last
let username = $state(initial?.username ?? store.servers.at(-1)?.username ?? '');
let password = $state(initial?.password ?? '');
let identityId = $state(initial?.identityId ?? '');
let icon = $state(initial?.icon ?? '');
+7 -3
View File
@@ -16,7 +16,7 @@
x: Math.max(4, Math.min(m.x, window.innerWidth - r.width - 4)),
y: Math.max(4, Math.min(m.y, window.innerHeight - r.height - 4))
};
el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
if (!m.keepFocus) el.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
function onkeydown(e: KeyboardEvent) {
@@ -39,7 +39,9 @@
<div class="catcher" role="presentation" onmousedown={() => menu.close()} oncontextmenu={e => { e.preventDefault(); menu.close(); }}></div>
<div class="menu" role="menu" bind:this={el} style:left="{pos.x}px" style:top="{pos.y}px">
{#each menu.open.items as item, i (i)}
{#if item.separator}
{#if item.header}
<div class="head" role="presentation">{item.label}</div>
{:else if item.separator}
<div class="sep" role="separator"></div>
{:else if item.slider}
{@const sl = item.slider}
@@ -51,7 +53,7 @@
</div>
{:else}
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
onclick={() => { menu.close(); item.action?.(); }}>
onmousedown={e => e.preventDefault()} onclick={() => { menu.close(); item.action?.(); }}>
<span class="label">{item.label}</span>
{#if item.checked !== undefined}<span class="box" class:on={item.checked}></span>{/if}
</button>
@@ -74,6 +76,8 @@
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
.box.on { background: currentColor; }
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
.head { padding: 4px 10px 6px; margin-bottom: 4px; font-size: 12px; font-weight: 700; color: var(--text-dim); border-bottom: 1px solid var(--line);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slider { padding: 6px 10px 8px; }
.slider-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
.slider input { width: 100%; padding: 0; border: 0; background: transparent; accent-color: var(--accent); margin-top: 4px; }
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import Icon from './Icon.svelte';
// Formatting controls shared by the description editor and the message input.
// Buttons keep the text selection (mousedown is suppressed) and act through the callbacks.
let { exec, onlink, onimage, source = false, ontogglesource }:
{ exec: (cmd: string, arg?: string) => void; onlink: () => void; onimage: () => void; source?: boolean; ontogglesource: () => void } = $props();
let color = $state('#e8b23c');
const keep = (e: MouseEvent) => e.preventDefault();
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={keep} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={keep} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={keep} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={keep} onclick={onlink}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={keep} onclick={onimage}><Icon name="image" size={16} /></button>
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={keep} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={ontogglesource}><Icon name="code" size={16} /></button>
</div>
<style>
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
</style>
+11 -2
View File
@@ -3,11 +3,20 @@
import YouTubeEmbed from './YouTubeEmbed.svelte';
import 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' }));
</script>
@@ -22,13 +31,13 @@
{#if grouped}
<span class="time hover">{hhmm}</span>
{:else}
<Avatar name={entry.author} size={38} />
<span role="presentation" oncontextmenu={authorMenu}><Avatar name={entry.author} size={38} /></span>
{/if}
</div>
<div class="content">
{#if !grouped}
<header>
<strong class:self={entry.self}>{entry.author}</strong>
<strong class:self={entry.self} oncontextmenu={authorMenu}>{entry.author}</strong>
<time datetime={time.toISOString()}>{time.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' })}</time>
</header>
{/if}
+172
View File
@@ -0,0 +1,172 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Modal from './Modal.svelte';
import { native, type PublicServer, type PingResult } from '../lib/native.ts';
import { store } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
let { onclose }: { onclose: () => void } = $props();
let servers = $state<PublicServer[]>([]);
let loading = $state(true);
let error = $state('');
let search = $state('');
let continent = $state('');
let sort = $state<'users' | 'ping' | 'name' | 'country'>('users');
let pings = $state<Record<string, PingResult | null>>({});
let alive = true;
// Ping queue; the generation drops answers from a previous list
let queue: PublicServer[] = [];
let running = 0;
let generation = 0;
onDestroy(() => { alive = false; });
const key = (s: PublicServer) => `${s.host}:${s.port}`;
const CONTINENTS: Record<string, string> = { eu: 'Europe', na: 'North America', sa: 'South America', as: 'Asia', oc: 'Oceania', af: 'Africa', an: 'Antarctica' };
async function load() {
// A new list starts a new ping round; answers from the previous round are dropped
generation++;
queue = [];
running = 0;
loading = true;
error = '';
try {
servers = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
if (!native) error = 'The public list needs the desktop app.';
} catch (e) {
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
loading = false;
pingVisible();
}
load();
const shown = $derived.by(() => {
const q = search.trim().toLowerCase();
const list = servers.filter(s => (!continent || s.continent === continent) &&
(!q || s.name.toLowerCase().includes(q) || s.country.toLowerCase().includes(q) || s.host.toLowerCase().includes(q)));
const p = (s: PublicServer) => pings[key(s)];
return list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'country') return a.country.localeCompare(b.country) || a.name.localeCompare(b.name);
if (sort === 'ping') return (p(a)?.ping ?? 1e9) - (p(b)?.ping ?? 1e9);
return (p(b)?.users ?? -1) - (p(a)?.users ?? -1);
}).slice(0, 300);
});
// Ping what the filter shows, a few at a time; each answer fills in users and latency
function pingVisible() {
queue = servers.filter(s => !(key(s) in pings));
for (let i = running; i < 16; i++) next();
}
function next() {
const s = queue.shift();
if (!s || !alive || !native) return;
const round = generation;
running++;
pings[key(s)] = null;
native.pingServer(s.host, s.port).then(r => {
if (!alive || round !== generation) return;
pings[key(s)] = r;
running--;
next();
});
}
function add(s: PublicServer) {
ui.addServer = { host: s.host, port: s.port, label: s.name };
onclose();
}
function changeSource() {
ui.prompt = {
title: 'Public list address', label: 'URL', confirm: 'Use this list', initial: store.settings.publicListUrl,
text: 'The official list is publist.mumble.info. Any server returning the same XML format works.',
onsubmit: v => {
store.settings.publicListUrl = v.trim() || 'https://publist.mumble.info/v1/list?version=1.5.0';
store.saveSettings();
pings = {};
load();
}
};
}
</script>
<Modal title="Public servers" {onclose} width={900}>
<div class="bar">
<input bind:value={search} placeholder="Search by name, country or address" aria-label="Search servers" />
<select bind:value={continent} aria-label="Continent">
<option value="">All continents</option>
{#each Object.entries(CONTINENTS) as [code, name] (code)}<option value={code}>{name}</option>{/each}
</select>
<select bind:value={sort} aria-label="Sort by">
<option value="users">Most people</option>
<option value="ping">Lowest ping</option>
<option value="name">Name</option>
<option value="country">Country</option>
</select>
</div>
{#if loading}
<p class="dim">Loading the public server list...</p>
{:else if error}
<p class="error">Could not load the list: {error}</p>
<div class="row"><button class="btn" onclick={load}>Try again</button><button class="btn" onclick={changeSource}>Use another list...</button></div>
{:else}
<table>
<thead><tr><th>Server</th><th>Country</th><th class="num">People</th><th class="num">Ping</th><th></th></tr></thead>
<tbody>
{#each shown as s (key(s))}
{@const p = pings[key(s)]}
<tr>
<td class="name">
<span class="title" title={s.name}>{s.name}</span>
<span class="addr">{s.host}{s.port !== 64738 ? `:${s.port}` : ''}</span>
{#if s.url}<a class="site" href={s.url} target="_blank" rel="noopener noreferrer">website</a>{/if}
</td>
<td>{s.country}</td>
<td class="num">{p ? `${p.users} / ${p.maxUsers}` : key(s) in pings && p === null ? '...' : ''}</td>
<td class="num" class:good={p && p.ping < 60} class:bad={p && p.ping > 180}>{p ? `${Math.round(p.ping)} ms` : ''}</td>
<td class="act"><button class="btn" onclick={() => add(s)}>Add</button></td>
</tr>
{:else}
<tr><td colspan="5" class="dim">No servers match.</td></tr>
{/each}
</tbody>
</table>
<p class="dim small">{servers.length} servers listed{shown.length < servers.length ? `, ${shown.length} shown` : ''}. Pinging lets each server see your IP address, like in the desktop client.</p>
{/if}
{#snippet footer()}
<button class="link" onclick={changeSource}>List source: {(() => { try { return new URL(store.settings.publicListUrl).host; } catch { return store.settings.publicListUrl; } })()}</button>
<span class="grow"></span>
<button class="btn primary" onclick={onclose}>Close</button>
{/snippet}
</Modal>
<style>
.bar { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.bar input { flex: 1 1 240px; }
.bar select { width: auto; flex: 0 0 auto; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 14px; }
th { text-align: left; font-size: 12px; color: var(--text-faint); font-weight: 600; padding: 6px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); }
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
tr:hover td { background: var(--bg-hover); }
.name { max-width: 360px; }
.title { display: block; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.addr { font-size: 12px; color: var(--text-faint); }
.site { font-size: 12px; margin-left: 8px; }
.num { text-align: right; white-space: nowrap; }
.good { color: var(--speaking); }
.bad { color: var(--warn); }
.act { width: 1%; }
.act .btn { padding: 4px 12px; font-size: 13px; }
.dim { color: var(--text-dim); }
.small { font-size: 12px; margin-top: 10px; }
.error { color: var(--danger); }
.row { display: flex; gap: 8px; }
.link { color: var(--link); font-size: 13px; }
.grow { flex: 1; }
@media (max-width: 640px) { th:nth-child(2), td:nth-child(2) { display: none; } }
</style>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
// the right of the window is resized from its left edge and vice versa. Width is remembered.
let { width = $bindable(), edge, min, max, initial, storageKey, label }:
{ width: number; edge: 'left' | 'right'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
let resizing = $state(false);
function setWidth(w: number) {
width = Math.round(Math.max(min, Math.min(max(), w)));
try { localStorage.setItem(storageKey, String(width)); } catch { /* storage unavailable */ }
}
function start(e: PointerEvent) {
e.preventDefault();
const handle = e.currentTarget as HTMLElement;
handle.setPointerCapture(e.pointerId);
resizing = true;
document.body.style.userSelect = 'none';
const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : ev.clientX - box.left);
const up = () => {
resizing = false;
document.body.style.userSelect = '';
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function onkeydown(e: KeyboardEvent) {
const grow = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
const shrink = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
if (e.key === grow) setWidth(width + 16);
else if (e.key === shrink) setWidth(width - 16);
else return;
e.preventDefault();
}
</script>
<svelte:window onresize={() => setWidth(width)} />
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation="vertical" aria-label={label}
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
<style>
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
.resize.left { left: -4px; }
.resize.right { right: -4px; }
.resize:hover, .resize:focus-visible, .resize.active {
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
outline: none;
}
</style>
+3 -38
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import FormatToolbar from './FormatToolbar.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts';
@@ -17,7 +17,6 @@
let sourceText = $state('');
let html = $state('');
let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit);
@@ -96,34 +95,11 @@
if (cmd) { e.preventDefault(); exec(cmd); }
}
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<FormatToolbar {exec} onlink={link} onimage={() => picker.click()} {source} ontogglesource={toggleSource} />
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
@@ -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; }
+3 -52
View File
@@ -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 @@
</script>
<svelte:window onresize={() => setWidth(width)} />
<aside class="panel" class:resizing aria-label="Details" style:width="{width}px">
<!-- Focusable separator is the ARIA window-splitter pattern -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div class="resize" role="separator" aria-orientation="vertical" aria-label="Resize panel" aria-valuenow={width}
aria-valuemin={MIN_W} aria-valuemax={maxW()} tabindex="0" title="Drag to resize, double-click to reset"
onpointerdown={startResize} ondblclick={() => setWidth(DEFAULT_W)} onkeydown={resizeKey}></div>
<aside class="panel" aria-label="Details" style:width="{width}px">
<ResizeHandle bind:width edge="left" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.panelWidth" label="Resize panel" />
{#if panel.kind === 'members' || (!channel && !user)}
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
<Members {onnavigate} />
@@ -142,12 +107,6 @@
<h3>{store.displayName(user)}</h3>
<p class="where" title="Current channel"><Icon name="channel" size={14} /><span>{session.channel(user.channelId)?.name || 'Root'}</span></p>
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
<p class="badges">
{#if user.userId != null}<span class="badge-reg registered">Registered</span>{/if}
{#if isSelf && user.userId == null}
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
{/if}
</p>
<p class="flags">
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
@@ -192,9 +151,6 @@
<style>
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
.panel.resizing { user-select: none; }
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; left: -4px; width: 8px; cursor: col-resize; touch-action: none; }
.resize:hover, .resize:focus-visible, .resizing .resize { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); outline: none; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
@@ -217,11 +173,6 @@
.where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0; }
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
.register { font-size: 12px; color: var(--link); }
.register:hover { text-decoration: underline; }
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
.flags .bad { border-color: var(--danger); color: var(--danger); }
+4 -2
View File
@@ -5,8 +5,8 @@
import { menu } from '../lib/menu.svelte.ts';
import { makeServerIcon } from '../lib/upload.ts';
let { onadd, onedit, onsettings }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void } = $props();
let { onadd, onedit, onsettings, onbrowse }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onsettings: () => void; onbrowse: () => void } = $props();
const initials = (label: string) =>
label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
@@ -50,6 +50,7 @@
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
menu.show(e, [
{ header: true, label: s.label },
state === 'idle'
? { label: 'Connect', action: () => sessions.open(s) }
: { label: 'Disconnect', action: () => sessions.close(s.id) },
@@ -87,6 +88,7 @@
{#if dropIndex === store.servers.length && dragging}<div class="drop-line"></div>{/if}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="tile add" title="Add a server" onclick={onadd}><Icon name="plus" /></button>
<button class="tile add" title="Browse public servers" onclick={onbrowse}><Icon name="globe" /></button>
<div class="spacer"></div>
<button class="tile ghost" title="Settings" onclick={onsettings}><Icon name="settings" /></button>
</nav>
+11 -3
View File
@@ -2,14 +2,16 @@
import Modal from './Modal.svelte';
import VoiceSettings from './VoiceSettings.svelte';
import IdentityList from './IdentityList.svelte';
import AppearanceSettings from './AppearanceSettings.svelte';
import SoundSettings from './SoundSettings.svelte';
import { store } from '../lib/settings.svelte.ts';
import { native } from '../lib/native.ts';
import { fetchUploadInfo } from '../lib/upload.ts';
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'files' | 'identities' } = $props();
let { onclose, tab: initialTab = 'voice' }: { onclose: () => void; tab?: 'voice' | 'sounds' | 'appearance' | 'files' | 'identities' } = $props();
// svelte-ignore state_referenced_locally
let tab = $state<'voice' | 'files' | 'identities'>(initialTab);
let tab = $state<'voice' | 'sounds' | 'appearance' | 'files' | 'identities'>(initialTab);
const s = store.settings;
let embedHosts = $state(s.embedHosts.join('\n'));
let testResult = $state('');
@@ -33,9 +35,11 @@
}
}
type Tab = 'voice' | 'files' | 'identities';
type Tab = 'voice' | 'sounds' | 'appearance' | 'files' | 'identities';
const tabs: { id: Tab; label: string }[] = [
{ id: 'voice', label: 'Voice' },
{ id: 'sounds', label: 'Sounds' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'files', label: 'Chat and files' },
...(native ? [{ id: 'identities' as Tab, label: 'Identities' }] : [])
];
@@ -50,6 +54,10 @@
{#if tab === 'voice'}
<VoiceSettings />
{:else if tab === 'sounds'}
<SoundSettings />
{:else if tab === 'appearance'}
<AppearanceSettings />
{:else if tab === 'files'}
<p class="help">Files go to a f0ckm instance and are posted as links. Other mumh5 users see them inline; regular Mumble users get a link and a small preview. Without an upload host you can still send images, inline and scaled to the server's limit.</p>
<label for="s-host">Upload host</label>
+29 -6
View File
@@ -6,15 +6,37 @@
import { ui } from '../lib/ui.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { PERM } from '../core/client.ts';
import ResizeHandle from './ResizeHandle.svelte';
import { voice } from '../lib/audio/voice.svelte.ts';
let { onnavigate }: { onnavigate: () => void } = $props();
// resizable: false in the phone drawer, which keeps a fixed width
let { onnavigate, resizable = true }: { onnavigate: () => void; resizable?: boolean } = $props();
const MIN_W = 200, DEFAULT_W = 260;
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
let width = $state(Math.max(MIN_W, Number((() => { try { return localStorage.getItem('mumh5.sidebarWidth'); } catch { return null; } })()) || DEFAULT_W));
const roots = $derived(session.childrenOf(null));
const self = $derived(session.self);
const cert = $derived(session.serverCert);
const trusted = $derived(!!cert?.authorized);
// Right-click on free space in the channel list (rows open their own menus and stop the event)
function listMenu(e: MouseEvent) {
if (session.status !== 'connected') return;
const here = session.self?.channelId ?? 0;
const hereName = session.channel(here)?.name || 'Root';
const noMake = (id: number) => session.can(id, PERM.MakeChannel) === false && session.can(id, PERM.MakeTempChannel) === false;
menu.show(e, [
{ label: 'Create channel...', disabled: noMake(0), action: () => (ui.channelDialog = { mode: 'create', parent: 0 }) },
...(here !== 0 ? [{ label: `Create channel in ${hereName}...`, disabled: noMake(here), action: () => (ui.channelDialog = { mode: 'create', parent: here }) }] : []),
{ separator: true, label: '' },
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) }
]);
}
function openQuick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -36,7 +58,8 @@
);
</script>
<aside class="sidebar">
<aside class="sidebar" style:width={resizable ? `${width}px` : undefined}>
{#if resizable}<ResizeHandle bind:width edge="right" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
<header class:trusted>
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
@@ -52,7 +75,7 @@
{/if}
</header>
<div class="scroll">
<div class="scroll" role="presentation" oncontextmenu={listMenu}>
{#if session.status === 'connected'}
<ul class="tree">
{#each roots as c (c.id)}<ChannelNode channel={c} {onnavigate} />{/each}
@@ -72,7 +95,7 @@
{/each}
</ul>
{/if}
{:else if session.error}
{:else if session.error && !session.disconnectInfo}
<p class="error">{session.error}</p>
{:else if session.status === 'idle'}
<p class="hint">Pick a server on the left, or add one with the plus button.</p>
@@ -110,7 +133,7 @@
</aside>
<style>
.sidebar { width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
.sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
@@ -135,7 +158,7 @@
.elsewhere { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-0); border-top: 1px solid var(--line); }
.elsewhere span { flex: 1; min-width: 0; }
.elsewhere .btn { padding: 4px 10px; font-size: 12px; }
.me { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-0); }
.me { flex: none; height: var(--footer-h); display: flex; align-items: center; gap: 8px; padding: 0 8px; background: var(--bg-0); }
.profile { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 2px; text-align: left; }
.profile:hover { background: var(--bg-hover); }
.who { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.2; }
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { sounds, SOUND_EVENTS, type SoundEvent } from '../lib/audio/sounds.svelte.ts';
const s = sounds.settings;
let picker: HTMLInputElement;
let packPicker: HTMLInputElement;
let folderPicker: HTMLInputElement;
const groups = [...new Set(SOUND_EVENTS.map(e => e.group))];
let target: SoundEvent | null = null;
let report = $state<{ assigned: string[]; unmatched: string[] } | null>(null);
let error = $state('');
function choose(ev: SoundEvent) {
target = ev;
picker.click();
}
async function picked() {
const f = picker.files?.[0];
picker.value = '';
if (!f || !target) return;
error = (await sounds.setCustom(target, f)) ?? '';
if (!error) sounds.play(target, true);
}
async function pack(input: HTMLInputElement) {
const files = [...(input.files ?? [])];
input.value = '';
if (files.length) report = await sounds.loadPack(files);
}
</script>
<h3>Notification sounds</h3>
<label class="check"><input type="checkbox" checked={s.enabled} onchange={e => sounds.update({ enabled: e.currentTarget.checked })} /> Play sounds for events</label>
<label for="snd-vol">Volume: {Math.round(s.volume * 100)}%</label>
<input id="snd-vol" type="range" min="0" max="1" step="0.05" value={s.volume} oninput={e => sounds.update({ volume: Number(e.currentTarget.value) })} />
<div class="packrow">
<button class="btn" onclick={() => folderPicker.click()}>Load sound pack folder...</button>
<button class="btn" onclick={() => packPicker.click()}>Load sound pack...</button>
<button class="btn" onclick={() => { sounds.resetAll(); report = null; }}>Reset all to built-in</button>
<input bind:this={folderPicker} type="file" webkitdirectory multiple hidden onchange={() => pack(folderPicker)} />
<input bind:this={packPicker} type="file" accept="audio/*,.wav,.mp3,.ogg,.opus,.flac,.m4a,.ini" multiple hidden onchange={() => pack(packPicker)} />
</div>
<p class="help">A TeamSpeak 3 sound pack folder (with its <code>settings.ini</code>) is mapped exactly. Other files are matched by name, e.g. <code>user_joined.wav</code>. Any sound can also be set one by one below.</p>
{#if report}
<div class="report">
{#if report.assigned.length}<p>Assigned: {report.assigned.join('; ')}</p>{/if}
{#if report.unmatched.length}<p class="warn">Not matched (set these by hand): {report.unmatched.join(', ')}</p>{/if}
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
{#each sounds.errors as err (err)}<p class="error">Could not play {err}</p>{/each}
<input bind:this={picker} type="file" accept="audio/*,.wav,.mp3,.ogg,.opus,.flac,.m4a" hidden onchange={picked} />
{#each groups as g (g)}
<h4>{g}</h4>
<ul class="events" class:disabled={!s.enabled}>
{#each SOUND_EVENTS.filter(e => e.group === g) as e (e.id)}
{@const custom = s.custom[e.id]}
<li>
<label class="check ev"><input type="checkbox" checked={sounds.isOn(e.id)} onchange={x => sounds.setOn(e.id, x.currentTarget.checked)} /> {e.name}</label>
<span class="file" title={custom?.name ?? 'Built-in sound'}>{custom?.name ?? 'Built-in'}</span>
<button class="icon-btn" title="Play" aria-label="Play {e.name}" onclick={() => sounds.play(e.id, true)}><Icon name="channel" size={16} /></button>
<button class="link" onclick={() => choose(e.id)}>Choose file</button>
{#if custom}<button class="link" onclick={() => sounds.reset(e.id)}>Reset</button>{/if}
</li>
{/each}
</ul>
{/each}
<style>
h3 { margin: 8px 0 0; font-size: 14px; }
.check { display: flex; align-items: center; gap: 8px; text-transform: none; font-weight: 400; letter-spacing: 0; font-size: 14px; color: var(--text); margin-top: 10px; }
.check input { width: auto; }
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
.packrow { display: flex; align-items: center; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.packrow .btn { flex: none; }
h4 { margin: 16px 0 0; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
.help { font-size: 12px; color: var(--text-dim); }
code { font: 12px var(--mono); }
.report { margin-top: 8px; font-size: 13px; color: var(--text-dim); }
.report p { margin: 4px 0 0; }
.warn { color: var(--warn); }
.error { color: var(--danger); font-size: 13px; }
.events { list-style: none; margin: 6px 0 0; padding: 0; border-top: 1px solid var(--line); }
.events.disabled { opacity: 0.5; }
.events li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--line); }
.ev { flex: 1; margin: 0; min-width: 0; }
.file { max-width: 160px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: var(--link); font-size: 13px; white-space: nowrap; }
</style>