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
+63
View File
@@ -0,0 +1,63 @@
import { native } from './native.ts';
import { menu, type MenuItem } from './menu.svelte.ts';
// Right-click menu for text fields, messages, links and images. Elements with their own menu
// (users, channels, servers) call preventDefault, so Electron does not report those.
export function startTextMenu(): void {
if (!native) return;
const api = native;
api.onContextMenu(p => {
// Edit commands act on the focused element; keep it focused through the menu
const field = p.isEditable ? (document.activeElement as HTMLElement | null) : null;
const edit = (action: string, arg?: unknown) => { field?.focus(); api.editAction(action, arg); };
const items: MenuItem[] = [];
const add = (group: MenuItem[]) => {
if (!group.length) return;
if (items.length) items.push({ separator: true, label: '' });
items.push(...group);
};
if (p.misspelledWord) {
add([
...p.suggestions.map(s => ({ label: s, action: () => edit('replaceMisspelling', s) })),
...(p.suggestions.length ? [] : [{ label: 'No suggestions', disabled: true }]),
{ label: 'Add to dictionary', action: () => api.editAction('addWord', p.misspelledWord) }
]);
}
if (p.isEditable) {
const f = p.editFlags;
add([
{ label: 'Undo', disabled: !f.canUndo, action: () => edit('undo') },
{ label: 'Redo', disabled: !f.canRedo, action: () => edit('redo') }
]);
add([
{ label: 'Cut', disabled: !f.canCut, action: () => edit('cut') },
{ label: 'Copy', disabled: !f.canCopy, action: () => edit('copy') },
{ label: 'Paste', disabled: !f.canPaste, action: () => edit('paste') }
]);
add([{ label: 'Select all', disabled: !f.canSelectAll, action: () => edit('selectAll') }]);
} else {
if (p.selectionText.trim()) add([{ label: 'Copy', action: () => api.editAction('copy') }]);
if (p.linkURL && /^https?:/i.test(p.linkURL)) {
add([
{ label: 'Open link', action: () => window.open(p.linkURL, '_blank', 'noopener') },
{ label: 'Copy link address', action: () => navigator.clipboard.writeText(p.linkURL) }
]);
}
if (p.mediaType === 'image' && p.srcURL) {
add([
{ label: 'Copy image', action: () => api.editAction('copyImageAt', { x: p.x, y: p.y }) },
{ label: 'Save image...', action: () => api.editAction('saveImage', p.srcURL) }
]);
}
// The message under the pointer, as plain text
const msg = document.elementFromPoint(p.x, p.y)?.closest('.msg, .system');
const text = msg?.querySelector('.html')?.textContent?.trim();
if (text) add([{ label: 'Copy message text', action: () => navigator.clipboard.writeText(text) }]);
}
if (items.length) menu.showAt(p.x, p.y, items, true);
});
}