Files
mumh5/src/lib/actions.ts
T
kibiandClaude Opus 5.5 9a3aeb29ad 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>
2026-10-01 00:54:03 +02:00

125 lines
6.7 KiB
TypeScript

import { session } from './session.svelte.ts';
import { ui } from './ui.svelte.ts';
import { store } from './settings.svelte.ts';
import { voice, userKey } from './audio/voice.svelte.ts';
import { sounds } from './audio/sounds.svelte.ts';
import type { MenuItem } from './menu.svelte.ts';
import { PERM, type Channel, type User } from '../core/client.ts';
// Right-click menu for a channel in the tree
export function channelMenu(c: Channel): MenuItem[] {
const here = session.self?.channelId === c.id;
const mine = session.self?.channelId ?? 0;
// Greyed out only when the server said no; unknown permissions stay enabled
const no = (bit: number) => session.can(c.id, bit) === false;
const linked = c.links.has(mine);
const manage: MenuItem[] = [
{ separator: true, label: '' },
{ label: 'Create channel...', disabled: no(PERM.MakeChannel) && no(PERM.MakeTempChannel), action: () => (ui.channelDialog = { mode: 'create', parent: c.id }) },
{ label: 'Edit channel...', disabled: no(PERM.Write), action: () => (ui.channelDialog = { mode: 'edit', id: c.id }) },
{ label: 'Permissions...', disabled: no(PERM.Write), action: () => (ui.channelDialog = { mode: 'edit', id: c.id, tab: 'permissions' }) }
];
if (!here) {
manage.push(linked
? { label: 'Unlink from my channel', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [], [mine]) }
: { label: 'Link to my channel', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [mine], []) });
}
if (c.links.size) manage.push({ label: 'Unlink all', disabled: no(PERM.LinkChannel), action: () => session.client?.setLinks(c.id, [], [...c.links]) });
if (c.id !== 0) {
manage.push({
label: 'Delete channel...', danger: true, disabled: no(PERM.Write),
action: () => (ui.prompt = {
title: `Delete ${c.name}?`, label: '', confirm: 'Delete channel', danger: true, input: 'none',
text: 'The channel, its subchannels and their permissions are removed for everyone. People inside are moved out.',
onsubmit: () => { session.client?.removeChannel(c.id); sounds.play('channelDeleted'); }
})
});
}
return [
{ header: true, label: c.name || 'Root' },
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
{ label: 'Open chat', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
{ separator: true, label: '' },
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
{ label: 'Expand all', action: () => session.setAllExpanded(true) },
{ label: 'Collapse all', action: () => session.setAllExpanded(false) },
{ separator: true, label: '' },
{ label: 'Copy channel name', action: () => navigator.clipboard.writeText(c.name || 'Root') },
...manage
];
}
// Right-click menu for a user. Moderation entries are always offered; the server decides
// whether we may use them and answers with a permission denial otherwise.
export function userMenu(u: User): MenuItem[] {
const self = session.self;
const isSelf = u.session === self?.session;
const items: MenuItem[] = [
{ header: true, label: store.displayName(u) },
{ label: 'View profile', action: () => { session.showUser(u.session); ui.panelOpen = true; } }
];
if (isSelf) {
items.push(
{ label: 'Mute', checked: u.selfMute, action: () => session.setSelfMute(!u.selfMute) },
{ label: 'Deafen', checked: u.selfDeaf, action: () => session.setSelfDeaf(!u.selfDeaf) },
{ label: 'Change name...', action: () => changeNamePrompt(u) }
);
if (u.userId == null) items.push({ label: 'Register on this server', action: () => session.register(u.session) });
} else {
items.push(
{ label: 'Send message', action: () => session.selectView(`dm:${u.session}`) },
{ label: 'Mute for me', checked: !!voice.settings.localMutes[userKey(u)], action: () => voice.setLocalMute(u, !voice.settings.localMutes[userKey(u)]) },
{
label: 'Volume',
slider: {
value: voice.settings.userVolumes[userKey(u)] ?? 1, min: 0, max: 3, step: 0.05,
format: v => `${Math.round(v * 100)}%`,
oninput: v => voice.setUserVolume(u, v)
}
},
{ label: 'Set nickname...', action: () => nicknamePrompt(u) }
);
if (self && u.channelId !== self.channelId) {
items.push({ label: 'Move to my channel', action: () => session.moveUser(u.session, self.channelId) });
}
}
items.push(
{ separator: true, label: '' },
{ label: 'Server mute', checked: u.mute, action: () => session.setServerMute(u.session, !u.mute) },
{ label: 'Server deafen', checked: u.deaf, action: () => session.setServerDeaf(u.session, !u.deaf) },
{ label: 'Priority speaker', checked: u.prioritySpeaker, action: () => session.setPrioritySpeaker(u.session, !u.prioritySpeaker) }
);
if (!isSelf && u.userId == null) items.push({ label: 'Register user', action: () => session.register(u.session) });
if (!isSelf) {
items.push(
{ separator: true, label: '' },
{ label: 'Kick...', danger: true, action: () => (ui.prompt = { title: `Kick ${u.name}`, label: 'Reason (optional)', confirm: 'Kick', danger: true, onsubmit: r => session.kick(u.session, r) }) },
{ label: 'Ban...', danger: true, action: () => (ui.prompt = { title: `Ban ${u.name}`, label: 'Reason (optional)', confirm: 'Ban', danger: true, onsubmit: r => session.kick(u.session, r, true) }) }
);
}
items.push({ separator: true, label: '' }, { label: 'Copy name', action: () => navigator.clipboard.writeText(u.name) });
if (u.hash) items.push({ label: 'Copy certificate hash', action: () => navigator.clipboard.writeText(u.hash) });
items.push({ separator: true, label: '' }, { label: 'Information', action: () => (ui.userInfo = u.session) });
return items;
}
export function changeNamePrompt(u: User): void {
ui.prompt = {
title: 'Change your name', label: 'New name', confirm: 'Reconnect with this name', initial: u.name,
text: u.userId != null
? 'You are registered here, so the server keeps your registered name. The new name applies on servers where you are a guest.'
: 'Mumble cannot rename you while connected, so mumh5 reconnects with the new name and remembers it for this server.',
onsubmit: name => session.changeName(name)
};
}
export function nicknamePrompt(u: User): void {
ui.prompt = {
title: `Nickname for ${u.name}`, label: 'Nickname (only you see it; empty to remove)', confirm: 'Save',
initial: store.nicknames[userKey(u)] ?? '',
onsubmit: nick => store.setNickname(userKey(u), nick)
};
}