Initial commit: mumh5, a modern Mumble client
Electron desktop app with a Svelte 5 interface for any Mumble server. - Mumble protocol core: TLS, handshake, channels, users, text, plugin data, client-side pacing of Murmur's rate limits - Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk, always-on, devices, per-user volume and local mute - Several servers at once, voice on one; server rail with icons and ordering - Chat: channels, direct messages, side chat, file sharing through f0ckm, inline images without it, click-to-play YouTube - Profiles with rich descriptions, registration, rename, nicknames, connection information and moderation menus - Identity wizard, multiple identities, PKCS#12 import/export, desktop Mumble certificate import, certificate pinning and viewer - Tray icon with voice state - Unit, server and end-to-end tests against a real Murmur Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
|
||||
import { makeInlineImage } from '../lib/upload.ts';
|
||||
import { ui } from '../lib/ui.svelte.ts';
|
||||
|
||||
// Rich text in the subset Mumble clients render (Qt rich text): bold, italic, underline,
|
||||
// strikethrough, colors, lists, links and inline data: images. `limit` is the server's
|
||||
// size limit for the whole HTML, pictures included.
|
||||
let { value = '', limit, placeholder = '', onchange }:
|
||||
{ value?: string; limit: number; placeholder?: string; onchange: (html: string) => void } = $props();
|
||||
|
||||
let editor: HTMLDivElement;
|
||||
let picker: HTMLInputElement;
|
||||
let source = $state(false);
|
||||
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);
|
||||
|
||||
onMount(() => {
|
||||
document.execCommand('defaultParagraphSeparator', false, 'p');
|
||||
document.execCommand('styleWithCSS', false, 'true');
|
||||
editor.innerHTML = toMumbleHtml(value) || '<p><br></p>';
|
||||
sync();
|
||||
});
|
||||
|
||||
function sync() {
|
||||
html = toMumbleHtml(editor.innerHTML);
|
||||
onchange(html);
|
||||
}
|
||||
|
||||
function exec(cmd: string, arg?: string) {
|
||||
editor.focus();
|
||||
document.execCommand(cmd, false, arg);
|
||||
sync();
|
||||
}
|
||||
|
||||
// window.prompt does not exist in Electron; use the app's dialog and restore the selection after
|
||||
function link() {
|
||||
const sel = window.getSelection();
|
||||
const range = sel && sel.rangeCount && editor.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;
|
||||
editor.focus();
|
||||
if (range) { sel!.removeAllRanges(); sel!.addRange(range); }
|
||||
if (range && !range.collapsed) exec('createLink', url);
|
||||
else exec('insertHTML', `<a href="${url.replace(/"/g, '"')}">${url.replace(/</g, '<')}</a>`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function insertImage(file: File) {
|
||||
error = '';
|
||||
// Room left for the picture inside the server's limit
|
||||
const room = (limit || 131072) - size - 64;
|
||||
const uri = await makeInlineImage(file, room, [640, 480, 320, 200, 120]);
|
||||
if (!uri) {
|
||||
error = room < 2000 ? 'No room left for a picture within the server limit.' : 'Could not make this picture small enough.';
|
||||
return;
|
||||
}
|
||||
exec('insertImage', uri);
|
||||
}
|
||||
|
||||
function onpaste(e: ClipboardEvent) {
|
||||
const img = [...(e.clipboardData?.files ?? [])].find(f => f.type.startsWith('image/'));
|
||||
e.preventDefault();
|
||||
if (img) return insertImage(img);
|
||||
const pasted = e.clipboardData?.getData('text/html');
|
||||
if (pasted) exec('insertHTML', toMumbleHtml(pasted));
|
||||
else exec('insertText', e.clipboardData?.getData('text/plain') ?? '');
|
||||
}
|
||||
|
||||
function toggleSource() {
|
||||
if (!source) {
|
||||
sourceText = html;
|
||||
source = true;
|
||||
} else {
|
||||
editor.innerHTML = toMumbleHtml(sourceText) || '<p><br></p>';
|
||||
source = false;
|
||||
sync();
|
||||
}
|
||||
}
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const map: Record<string, string> = { b: 'bold', i: 'italic', u: 'underline' };
|
||||
const cmd = map[e.key.toLowerCase()];
|
||||
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>
|
||||
|
||||
<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>
|
||||
{#if source}
|
||||
<textarea class="area src" bind:value={sourceText} oninput={() => onchange(toMumbleHtml(sourceText))} aria-label="HTML source" spellcheck="false"></textarea>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
{#if error}<span class="err">{error}</span>{/if}
|
||||
<span class="grow"></span>
|
||||
<span class="count" title="Size of the description as the server counts it">{formatBytes(source ? sourceText.length : size)}{limit ? ` / ${formatBytes(limit)}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
.area :global(img) { max-width: 100%; }
|
||||
.area.blank::before { content: attr(data-placeholder); color: var(--text-faint); position: absolute; left: 10px; top: 8px; pointer-events: none; }
|
||||
.src { width: 100%; border: 0; resize: vertical; font: 12px var(--mono); background: transparent; }
|
||||
.foot { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-top: 1px solid var(--line); font-size: 12px; color: var(--text-faint); }
|
||||
.over .count { color: var(--danger); font-weight: 600; }
|
||||
.err { color: var(--danger); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user