Text channels between mumh5 users; drag channels in front of or behind another

Text channels are real Mumble channels with a marker in the description.
Their messages travel as plugin data to everyone on the server, are kept
by the clients and handed to people who connect later.

Channels can be dropped in front of or behind another channel, with a
line where they land. A description preview no longer sticks after a drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 20:16:03 +02:00
co-authored by Claude Opus 5.5
parent 06a185d778
commit 30a75a0629
18 changed files with 892 additions and 54 deletions
+25
View File
@@ -0,0 +1,25 @@
// Where a channel sits among its siblings. Mumble sorts by position, then by name, so a
// channel dropped between two others needs a position that puts it exactly there.
export interface Ordered { id: number; position: number; name: string }
export const byOrder = (a: Ordered, b: Ordered) => a.position - b.position || a.name.localeCompare(b.name);
// Position changes that put `moved` at `index` among `siblings` (which need not contain it).
// One change when there is room between the neighbours; otherwise the row is numbered again.
export function planMove(siblings: Ordered[], moved: Ordered, index: number): { id: number; position: number }[] {
const rest = siblings.filter(c => c.id !== moved.id).sort(byOrder);
const at = Math.max(0, Math.min(index, rest.length));
const prev = rest[at - 1];
const next = rest[at];
const fits = (position: number) => {
const m = { ...moved, position };
return (!prev || byOrder(prev, m) < 0) && (!next || byOrder(m, next) < 0);
};
const tries = [moved.position, prev?.position, next?.position, prev && prev.position + 1, next && next.position - 1];
for (const p of tries) {
if (p == null || !fits(p)) continue;
return p === moved.position ? [] : [{ id: moved.id, position: p }];
}
const order = [...rest.slice(0, at), moved, ...rest.slice(at)];
return order.map((c, i) => ({ id: c.id, position: i })).filter((u, i) => order[i].position !== u.position);
}
+20 -6
View File
@@ -47,7 +47,7 @@ async function inflate(bytes: Uint8Array): Promise<Uint8Array | null> {
}
// One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data]
export async function encodeShare(type: ShareType, text?: string): Promise<Uint8Array[]> {
export async function encodeSignal(type: number, text?: string): Promise<Uint8Array[]> {
if (text == null) return [new Uint8Array([type])];
const data = await deflate(new TextEncoder().encode(text));
const total = Math.max(1, Math.ceil(data.length / CHUNK));
@@ -62,15 +62,23 @@ export async function encodeShare(type: ShareType, text?: string): Promise<Uint8
});
}
export interface ShareSignal { type: ShareType; text?: string }
export const encodeShare = (type: ShareType, text?: string) => encodeSignal(type, text);
export interface Signal<T extends number> { type: T; text?: string }
export type ShareSignal = Signal<ShareType>;
// Puts chunked signals back together, per sender
export class ShareAssembler {
export class SignalAssembler<T extends number> {
private pending = new Map<string, { parts: (Uint8Array | undefined)[]; got: number; at: number }>();
private types: readonly T[];
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<ShareSignal | null> {
const type = packet[0] as ShareType;
if (!Object.values(SHARE).includes(type)) return null;
constructor(types: readonly T[]) {
this.types = types;
}
async push(sender: number, packet: Uint8Array, now = Date.now()): Promise<Signal<T> | null> {
const type = packet[0] as T;
if (!this.types.includes(type)) return null;
if (packet.length === 1) return { type };
if (packet.length < 4) return null;
const [, id, index, total] = packet;
@@ -95,3 +103,9 @@ export class ShareAssembler {
return plain ? { type, text: new TextDecoder().decode(plain) } : null;
}
}
export class ShareAssembler extends SignalAssembler<ShareType> {
constructor() {
super(Object.values(SHARE));
}
}
+102
View File
@@ -0,0 +1,102 @@
// Text channels between mumh5 clients. Mumble only delivers a channel message to the people
// sitting in that channel, so a channel you read while you are in voice elsewhere cannot be
// a plain Mumble chat. A text channel is a normal Mumble channel with a marker in its
// description; its messages travel as plugin data to everyone on the server, which other
// clients ignore. The server stores nothing: clients keep the messages and hand the recent
// ones to whoever connects later.
import { encodeSignal, SignalAssembler } from './share-signal.ts';
export const TEXT_DATA_ID = 'mumh5.text';
export const TEXT = {
hello: 1, // I just connected: who else runs mumh5?
here: 2, // answer to hello
msg: 3, // a message: { i, c, h }
want: 4, // send me the recent messages
history: 5 // answer to want, one per channel: { c, m: [...] }
} as const;
export type TextType = typeof TEXT[keyof typeof TEXT];
// An HTML comment: invisible in every client, and Murmur keeps it
export const TEXT_MARK = '<!--mumh5:text-->';
export const isTextChannel = (description: string) => description.includes(TEXT_MARK);
export const stripMark = (description: string) => description.split(TEXT_MARK).join('');
export const markText = (description: string, on: boolean) => (on ? TEXT_MARK : '') + stripMark(description);
// Kept per channel, and handed to a newcomer
export const KEEP = 200;
export const HAND_OVER = 50;
// Compressed, this stays well inside the 32 packets a signal may take
export const MAX_HTML = 24000;
// i: id, c: channel, t: time, n: author name, k: author certificate hash, h: html
export interface TextMsg { i: string; c: number; t: number; n: string; k: string; h: string }
export const encodeText = (type: TextType, body?: unknown) => encodeSignal(type, body === undefined ? undefined : JSON.stringify(body));
export class TextAssembler extends SignalAssembler<TextType> {
constructor() {
super(Object.values(TEXT));
}
}
export function newId(): string {
return Date.now().toString(36) + Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(36)).join('');
}
function parse(text: string | undefined): Record<string, unknown> | null {
try {
const v = JSON.parse(text ?? '');
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
} catch {
return null;
}
}
const okId = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 40;
const okChannel = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0;
const okHtml = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= MAX_HTML;
// A live message; who sent it and when is filled in by the receiver
export function parseMsg(text: string | undefined): { i: string; c: number; h: string } | null {
const v = parse(text);
return v && okId(v.i) && okChannel(v.c) && okHtml(v.h) ? { i: v.i, c: v.c, h: v.h } : null;
}
// Messages another client kept. Times from the future are pulled back to now.
export function parseHistory(text: string | undefined, now = Date.now()): TextMsg[] {
const v = parse(text);
if (!v || !okChannel(v.c) || !Array.isArray(v.m)) return [];
const c = v.c;
const out: TextMsg[] = [];
for (const m of v.m.slice(-HAND_OVER) as Record<string, unknown>[]) {
if (!m || !okId(m.i) || !okHtml(m.h) || typeof m.t !== 'number' || !Number.isFinite(m.t)) continue;
if (typeof m.n !== 'string' || m.n.length > 200 || typeof m.k !== 'string' || m.k.length > 200) continue;
out.push({ i: m.i, c, t: Math.min(m.t, now), n: m.n, k: m.k, h: m.h });
}
return out;
}
// Adds what is not there yet, in order of time. Returns the new list and what was added.
export function mergeMsgs(have: TextMsg[], incoming: TextMsg[], keep = KEEP): { all: TextMsg[]; added: TextMsg[] } {
const ids = new Set(have.map(m => m.i));
const added: TextMsg[] = [];
for (const m of incoming) {
if (ids.has(m.i)) continue;
ids.add(m.i);
added.push(m);
}
if (!added.length) return { all: have, added };
const all = [...have, ...added].sort((a, b) => a.t - b.t).slice(-keep);
return { all, added: added.filter(m => all.includes(m)) };
}
// The newest messages of a channel that still fit one signal
export async function encodeHistory(c: number, msgs: TextMsg[]): Promise<Uint8Array[]> {
for (let n = Math.min(HAND_OVER, msgs.length); n > 0; n = Math.floor(n / 2)) {
try {
return await encodeText(TEXT.history, { c, m: msgs.slice(-n).map(({ i, t, n: name, k, h }) => ({ i, t, n: name, k, h })) });
} catch { /* too large: hand over fewer */ }
}
return [];
}
+9 -3
View File
@@ -36,11 +36,17 @@ export function channelMenu(c: Channel): MenuItem[] {
})
});
}
// A text channel is opened, not joined
const open: MenuItem[] = session.isText(c.id)
? [{ label: 'Open text channel', action: () => session.selectView(`ch:${c.id}`) }]
: [
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
// Opens the channel's chat in the side panel, ready to type, without joining it
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }
];
return [
{ header: true, label: c.name || 'Root' },
{ label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter },
// Opens the channel's chat in the side panel, ready to type, without joining it
{ label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } },
...open,
{ 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 },
+4 -1
View File
@@ -21,4 +21,7 @@ function run<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<
export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key));
export const getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(key));
export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key));
// Plain data that outgrows localStorage (text channel messages)
export const putData = (key: string, value: unknown) => run('readwrite', s => s.put(value, key));
export const getData = <T>(key: string) => run<T | undefined>('readonly', s => s.get(key));
export const deleteBlob =(key: string) => run('readwrite', s => s.delete(key));
+58 -7
View File
@@ -2,6 +2,8 @@ import { createCodec } from '../core/proto.ts';
import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, openTransport } from './native.ts';
import { share, type ShareKind } from './share.svelte.ts';
import { textChannels } from './text-channels.ts';
import { MAX_HTML, type TextMsg } from '../core/text-signal.ts';
import type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts';
@@ -188,6 +190,12 @@ export class Session {
}
// Whether a channel lies somewhere below another (a channel cannot move into its own subtree)
// A channel that is read and written without joining it (mumh5 clients only)
isText(channelId: number): boolean {
void this.tick;
return textChannels.isText(this, channelId);
}
isInside(channelId: number, ancestor: number): boolean {
let c = this.client?.channels.get(channelId);
while (c && c.parent != null) {
@@ -292,6 +300,7 @@ export class Session {
this.client = null;
client?.disconnect();
share.closed(this);
textChannels.closed(this);
this.sharing = {};
sessions.onClosed(this);
this.status = 'idle';
@@ -318,11 +327,17 @@ export class Session {
for (const id of client.channels.keys()) client.requestPermissions(id);
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
share.entered(this);
textChannels.entered(this);
bump();
});
client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); });
client.on('pluginData', (sender, dataId, data) => {
if (!live()) return;
share.receive(this, sender, dataId, data);
textChannels.receive(this, sender, dataId, data);
});
client.on('channel', ch => {
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
if (live() && client.synced) textChannels.channelChanged(this, ch.id);
bump();
});
client.on('permissions', () => {
@@ -334,6 +349,11 @@ export class Session {
client.on('channelRemove', id => {
if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
textChannels.channelRemoved(this, id);
// The chat on screen is gone: back to the channel we are in
if (this.view === `ch:${id}`) this.view = `ch:${client.self?.channelId ?? 0}`;
delete this.logs[`ch:${id}`];
delete this.unread[`ch:${id}`];
bump();
});
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
@@ -378,6 +398,7 @@ export class Session {
}
const self = client.self;
share.userMoved(this, user, true);
textChannels.userLeft(this, user.session);
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
const byOther = actor != null && actor !== user.session;
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
@@ -427,6 +448,7 @@ export class Session {
const kind = this.disconnectInfo?.kind;
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
this.client = null;
textChannels.closed(this);
sessions.onClosed(this);
this.status = 'idle';
this.error = reason === 'Disconnected' ? '' : reason;
@@ -519,11 +541,27 @@ export class Session {
// ─── Chat ───────────────────────────────────────────────────────────────────
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): void {
// time: for messages from earlier, which go where they belong in the log. quiet: not counted as unread.
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>, opts: { time?: number; quiet?: boolean } = {}): void {
// Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
if (!this.logs[key]) this.logs[key] = [];
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() });
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1;
const log = this.logs[key];
const time = opts.time ?? Date.now();
let at = log.length;
while (at > 0 && log[at - 1].time > time) at--;
log.splice(at, 0, { ...entry, id: nextId++, time });
if (!opts.quiet && (sessions.active !== this || !this.visibleKeys().includes(key))) this.unread[key] = (this.unread[key] ?? 0) + 1;
}
// A text channel message: live, kept from earlier, or handed over by another client
addText(m: TextMsg, self: boolean, quiet: boolean, sender: number | null = null): void {
const client = this.client;
if (!client) return;
const online = sender != null ? client.users.get(sender) : self ? client.self : m.k ? [...client.users.values()].find(u => u.hash === m.k) : undefined;
this.push(`ch:${m.c}`, {
kind: 'text', author: online ? store.displayName(online) : m.n, authorSession: online?.session ?? null, self,
...renderIncoming(m.h, store.trustedHosts)
}, { time: m.t, quiet });
}
private system(key: string, html: string, trusted = false): void {
@@ -592,6 +630,16 @@ export class Session {
sendHtml(html: string, key: string = this.view): boolean {
const client = this.client;
if (!client || this.status !== 'connected') return false;
if (key.startsWith('ch:') && this.isText(Number(key.slice(3)))) {
if (html.length > MAX_HTML) {
this.system(key, `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`);
return false;
}
// Shown in the log by the text channel code once it is on its way
textChannels.post(this, Number(key.slice(3)), html).then(problem => { if (problem) this.system(key, escapeHtml(problem)); });
sounds.play('messageSent');
return true;
}
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
if (limit && html.length > limit) {
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
@@ -623,6 +671,7 @@ export class Session {
const problem = checkFile(info, f);
if (problem) this.system(key, escapeHtml(problem));
}
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
for (const file of files.filter(f => !checkFile(info, f))) {
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
this.uploads.push(job);
@@ -633,7 +682,8 @@ export class Session {
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
let html = link;
const cfg = this.client?.config;
if (sendThumbnails && cfg?.allowHtml !== false) {
// Text channels only reach mumh5, which shows the file itself instead of a thumbnail
if (sendThumbnails && cfg?.allowHtml !== false && !textKey) {
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
const thumb = await makeThumbnail(file, budget);
if (thumb) html += `<br/><img src="${thumb}"/>`;
@@ -654,17 +704,18 @@ export class Session {
// scaled to the server's image limit. Other files need an upload host.
private async sendInlineImages(files: File[], key: string): Promise<void> {
const cfg = this.client?.config;
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
for (const file of files) {
if (!file.type.startsWith('image/')) {
this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`);
continue;
}
if (cfg?.allowHtml === false) {
if (cfg?.allowHtml === false && !textKey) {
this.system(key, 'This server does not allow images in messages.');
return;
}
// 0 means no limit; stay reasonable anyway
const limit = cfg?.imageMessageLength || 1024 * 1024;
const limit = textKey ? MAX_HTML : cfg?.imageMessageLength || 1024 * 1024;
const uri = await makeInlineImage(file, limit - 64);
if (!uri) {
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`);
+275
View File
@@ -0,0 +1,275 @@
// Text channels: channels you read and write without sitting in them (see core/text-signal.ts).
// Messages go to everyone on the server as plugin data; the server has checked the sender's
// certificate, so a live message is known to come from that user. Messages handed over by
// another client after connecting are that client's word for what was said.
import { TEXT, TEXT_DATA_ID, KEEP, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, newId, isTextChannel, type TextMsg, type TextType } from '../core/text-signal.ts';
import type { Session } from './session.svelte.ts';
import { getData, putData } from './blobstore.ts';
import { sounds } from './audio/sounds.svelte.ts';
// Murmur allows a burst of 15 plugin messages per user and 4 a second after that, and drops
// the rest silently. Screen sharing signals count against the same limit, so stay well under it.
const BURST = 6;
const PER_SECOND = 2.5;
// How long a client may take to hand over its messages before the next one is asked
const ANSWER_MS = 6000;
const MAX_ASKED = 3;
interface State {
assembler: TextAssembler;
msgs: Map<number, TextMsg[]>;
// Channels whose kept messages are in the chat log already
shown: Set<number>;
// What this computer kept has been read; nothing is shown before, or it would come out of order
loaded: boolean;
queue: { to: number[]; packet: Uint8Array }[];
level: number;
levelAt: number;
timer: ReturnType<typeof setTimeout> | undefined;
// Catching up: who answered our hello, who we asked, and whether that is settled
peers: number[];
asked: number[];
caughtUp: boolean;
askTimer: ReturnType<typeof setTimeout> | undefined;
saveTimer: ReturnType<typeof setTimeout> | undefined;
storeKey: string;
}
class TextChannels {
private states = new WeakMap<Session, State>();
// Plugin data needs a 1.4 server; older ones would not pass the messages on
supported(s: Session): boolean {
return !!s.client && s.client.serverVersionNum >= 0x010400;
}
isText(s: Session, channelId: number): boolean {
return this.supported(s) && isTextChannel(s.client!.channels.get(channelId)?.description ?? '');
}
// After connecting: bring back what this computer kept, then ask the others for what we missed
async entered(s: Session): Promise<void> {
const client = s.client;
if (!client || !this.supported(s) || !s.server) return;
const st: State = {
assembler: new TextAssembler(), msgs: new Map(), shown: new Set(), loaded: false, queue: [], level: 0, levelAt: Date.now(), timer: undefined,
peers: [], asked: [], caughtUp: false, askTimer: undefined, saveTimer: undefined,
storeKey: `text:${s.server.host.toLowerCase()}:${s.server.port}`
};
this.states.set(s, st);
// The marker is in the description, and long descriptions are only announced by hash
for (const c of client.channels.values()) s.loadDescription(c.id);
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
if (s.client !== client) return;
for (const [id, list] of Object.entries(kept ?? {})) {
if (Array.isArray(list)) st.msgs.set(Number(id), mergeMsgs(st.msgs.get(Number(id)) ?? [], list).all);
}
} catch { /* no storage: start empty */ }
if (s.client !== client) return;
st.loaded = true;
this.reveal(s);
this.send(s, this.everyone(s), TEXT.hello);
}
closed(s: Session): void {
const st = this.states.get(s);
if (!st) return;
clearTimeout(st.timer);
clearTimeout(st.askTimer);
if (st.saveTimer) { clearTimeout(st.saveTimer); this.save(st); }
this.states.delete(s);
}
// A channel appeared or its description changed
channelChanged(s: Session, channelId: number): void {
if (!this.states.has(s)) return;
s.loadDescription(channelId);
this.reveal(s);
}
// Murmur gives the id of a removed channel to a later one
channelRemoved(s: Session, channelId: number): void {
const st = this.states.get(s);
if (!st) return;
st.shown.delete(channelId);
st.msgs.delete(channelId);
this.forget(st, channelId);
}
userLeft(s: Session, session: number): void {
const st = this.states.get(s);
if (!st) return;
st.peers = st.peers.filter(p => p !== session);
// The one we were waiting for is gone: ask the next
if (!st.caughtUp && st.asked[st.asked.length - 1] === session) this.askNext(s, st);
}
// Sends a message to a text channel. The caller shows it in the log.
async post(s: Session, channelId: number, html: string): Promise<string | null> {
const st = this.states.get(s);
const self = s.client?.self;
if (!st || !self) return 'Not connected.';
if (html.length > MAX_HTML) return `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`;
const m: TextMsg = { i: newId(), c: channelId, t: Date.now(), n: self.name, k: self.hash, h: html };
let packets: Uint8Array[];
try {
packets = await encodeText(TEXT.msg, { i: m.i, c: m.c, h: m.h });
} catch {
return 'Message too long for a text channel.';
}
const to = this.everyone(s);
for (const packet of packets) st.queue.push({ to, packet });
this.flush(s, st);
this.keep(st, [m]);
st.shown.add(channelId);
s.addText(m, true, false);
return null;
}
async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise<void> {
const st = this.states.get(s);
if (dataId !== TEXT_DATA_ID || !st) return;
const signal = await st.assembler.push(sender, data);
const client = s.client;
if (!signal || !client || this.states.get(s) !== st) return;
switch (signal.type) {
case TEXT.hello:
this.send(s, [sender], TEXT.here);
break;
case TEXT.here:
if (!st.peers.includes(sender)) st.peers.push(sender);
if (!st.caughtUp && !st.asked.length) this.askNext(s, st);
break;
case TEXT.msg: {
const m = parseMsg(signal.text);
const from = client.users.get(sender);
const ch = m && client.channels.get(m.c);
// Only into text channels, or channels whose description has not arrived yet
if (!m || !from || !ch || !(isTextChannel(ch.description) || (ch.descriptionHash && !ch.description))) return;
const msg: TextMsg = { ...m, t: Date.now(), n: from.name, k: from.hash };
if (!this.keep(st, [msg]).length) return;
if (st.shown.has(m.c)) {
s.addText(msg, false, false, sender);
sounds.play('message');
}
break;
}
case TEXT.want: {
for (const [c, list] of st.msgs) {
if (!this.isText(s, c) || !list.length) continue;
for (const packet of await encodeHistory(c, list)) st.queue.push({ to: [sender], packet });
}
// Also when there is nothing, so the asker stops waiting
for (const packet of await encodeText(TEXT.history, { c: 0, m: [] })) st.queue.push({ to: [sender], packet });
this.flush(s, st);
break;
}
case TEXT.history: {
// Only from the client we asked
if (st.asked[st.asked.length - 1] !== sender) return;
st.caughtUp = true;
clearTimeout(st.askTimer);
const list = parseHistory(signal.text);
if (!list.length || !client.channels.has(list[0].c)) return;
const added = this.keep(st, list);
if (st.shown.has(list[0].c)) for (const m of added) s.addText(m, this.mine(s, m), true);
break;
}
}
}
private mine(s: Session, m: TextMsg): boolean {
const self = s.client?.self;
return !!self && (m.k ? m.k === self.hash : m.n === self.name);
}
private everyone(s: Session): number[] {
const client = s.client;
return client ? [...client.users.keys()].filter(id => id !== client.session) : [];
}
private askNext(s: Session, st: State): void {
clearTimeout(st.askTimer);
const next = st.peers.find(p => !st.asked.includes(p));
if (next == null || st.asked.length >= MAX_ASKED) return;
st.asked.push(next);
this.send(s, [next], TEXT.want);
st.askTimer = setTimeout(() => { if (!st.caughtUp && this.states.get(s) === st) this.askNext(s, st); }, ANSWER_MS);
}
// Puts the kept messages of text channels into their chat logs, once per channel
private reveal(s: Session): void {
const st = this.states.get(s);
if (!st?.loaded) return;
for (const [c, list] of st.msgs) {
if (st.shown.has(c) || !this.isText(s, c)) continue;
st.shown.add(c);
for (const m of list) s.addText(m, this.mine(s, m), true);
}
for (const c of s.client?.channels.keys() ?? []) if (this.isText(s, c)) st.shown.add(c);
}
private keep(st: State, list: TextMsg[]): TextMsg[] {
if (!list.length) return [];
const c = list[0].c;
const { all, added } = mergeMsgs(st.msgs.get(c) ?? [], list, KEEP);
if (added.length) {
st.msgs.set(c, all);
this.saveSoon(st);
}
return added;
}
private saveSoon(st: State): void {
clearTimeout(st.saveTimer);
st.saveTimer = setTimeout(() => this.save(st), 1000);
}
// Merged into what is stored: a second connection to the same server writes there too
private async save(st: State): Promise<void> {
st.saveTimer = undefined;
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey) ?? {};
for (const [c, list] of st.msgs) kept[c] = mergeMsgs(Array.isArray(kept[c]) ? kept[c] : [], list).all;
await putData(st.storeKey, kept);
} catch { /* storage unavailable */ }
}
private async forget(st: State, channelId: number): Promise<void> {
try {
const kept = await getData<Record<number, TextMsg[]>>(st.storeKey);
if (!kept || !(channelId in kept)) return;
delete kept[channelId];
await putData(st.storeKey, kept);
} catch { /* storage unavailable */ }
}
private async send(s: Session, to: number[], type: TextType, body?: unknown): Promise<void> {
const st = this.states.get(s);
if (!st || !to.length) return;
for (const packet of await encodeText(type, body)) st.queue.push({ to, packet });
this.flush(s, st);
}
private flush(s: Session, st: State): void {
if (st.timer) return;
const client = s.client;
while (st.queue.length && client && this.states.get(s) === st) {
const now = Date.now();
st.level = Math.max(0, st.level - (now - st.levelAt) / 1000 * PER_SECOND);
st.levelAt = now;
if (st.level > BURST - 1) {
st.timer = setTimeout(() => { st.timer = undefined; this.flush(s, st); }, 1000 / PER_SECOND);
return;
}
st.level++;
const { to, packet } = st.queue.shift()!;
// Queued packets can outlive the people they were meant for
const there = to.filter(id => client.users.has(id));
if (there.length) client.sendPluginData(there, TEXT_DATA_ID, packet);
}
}
}
export const textChannels = new TextChannels();
+19 -4
View File
@@ -4,6 +4,9 @@
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';
import { isTextChannel, markText, stripMark } from '../core/text-signal.ts';
import { textChannels } from '../lib/text-channels.ts';
import { sessions } from '../lib/session.svelte.ts';
type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
@@ -30,7 +33,11 @@
// svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? '');
let description = $state(initial?.description ?? '');
// The editor never sees the text channel marker; it is put back when saving
let description = $state(stripMark(initial?.description ?? ''));
let textChannel = $state(isTextChannel(initial?.description ?? ''));
// The marker is an HTML comment and the messages are plugin data
const textPossible = textChannels.supported(sessions.active) && session.serverInfo?.config.allowHtml !== false;
let position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false);
@@ -43,7 +50,7 @@
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId);
if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; }
if (!descLoaded && channel?.description) { description = stripMark(channel.description); textChannel = isTextChannel(channel.description); descLoaded = true; }
});
// ─── ACL ────────────────────────────────────────────────────────────────────
@@ -217,7 +224,7 @@
error = '';
if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; }
client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers });
client.createChannel(parentId!, name.trim(), { temporary, description: markText(description, textChannel), position, maxUsers });
sounds.play('channelCreated');
return onclose();
}
@@ -225,7 +232,9 @@
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;
// An untouched description may not start with the marker; that alone is no change
const changed = description !== stripMark(ch.description) || textChannel !== isTextChannel(ch.description);
if (descLoaded && changed) fields.description = markText(description, textChannel);
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);
@@ -268,6 +277,12 @@
<label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if}
</div>
{#if !isRoot}
<label class="check top" title="Read and written without joining, from any channel. Only mumh5 shows it this way; in other Mumble clients it is an ordinary channel and its messages are not visible there. The server keeps no messages: mumh5 clients keep them and pass the recent ones on to people who connect later. Everyone on the server who uses mumh5 can read it.">
<input type="checkbox" bind:checked={textChannel} disabled={!textPossible} /> Text channel (mumh5 only)
</label>
{#if !textPossible}<p class="dim small">Text channels need a server from version 1.4 that allows HTML.</p>{/if}
{/if}
<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>
+117 -28
View File
@@ -14,6 +14,8 @@
import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts';
import type { Channel } from '../core/client.ts';
import { stripMark } from '../core/text-signal.ts';
import { planMove } from '../core/channel-order.ts';
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -21,12 +23,14 @@
const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === channel.id);
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
// Read and written without joining: a click opens its chat
const isText = $derived(session.isText(channel.id));
const selected = $derived((session.panel.kind === 'channel' && session.panel.id === channel.id) || (isText && session.view === `ch:${channel.id}`));
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id));
// Resting on a channel that has a description for 1 second opens a quick preview next to its row
const hasDescription = $derived(!!(channel.description || channel.descriptionHash));
const hasDescription = $derived(!!(stripMark(channel.description) || (!channel.description && channel.descriptionHash)));
const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
let preview = $state<{ x: number; y: number } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
@@ -40,6 +44,8 @@
trackPointer(e);
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
// Not while something is dragged, and not when the row moved away in the meantime
if (drag.item || !rowEl?.matches(':hover')) return;
// Long descriptions are only announced by hash; the text is fetched now
session.loadDescription(channel.id);
// Just below and right of the pointer, kept inside the window
@@ -52,6 +58,20 @@
// An image opened from the preview covers it; the preview stays underneath
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250);
}
// A row that moves away under the pointer (after a drop) never reports the pointer leaving,
// so an open preview also watches where the pointer is
let rowEl = $state<HTMLElement>();
$effect(() => {
if (!preview) return;
let away: ReturnType<typeof setTimeout> | undefined;
const inside = () => !!rowEl?.matches(':hover') || !!popEl?.matches(':hover') || !!ui.image;
const check = () => {
if (inside()) { clearTimeout(away); away = undefined; }
else away ??= setTimeout(() => { away = undefined; if (!inside()) preview = null; }, 300);
};
window.addEventListener('mousemove', check);
return () => { window.removeEventListener('mousemove', check); clearTimeout(away); };
});
// Back from the image: the preview stays while the pointer is on it, and goes when it is not
let popEl = $state<HTMLElement>();
let hadImage = false;
@@ -80,44 +100,99 @@
};
}
// 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(() => {
// Drop a person to move them here. A channel dropped on the middle of a row becomes a
// subchannel; dropped on the upper or lower third it goes in front of or behind this one.
type Zone = 'before' | 'after' | 'into';
let zone = $state<Zone | null>(null);
const dragging = $derived(drag.item?.kind === 'channel' && drag.item.id === channel.id);
const open = $derived(expanded && (children.length > 0 || users.length > 0));
function intoAllowed(): boolean {
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
function zoneAt(e: DragEvent, row: HTMLElement): Zone | null {
const d = drag.item;
const ok = canDrop;
if (!d) return null;
const into = intoAllowed();
if (d.kind === 'user' || channel.id === 0) return into ? 'into' : null;
if (d.id === channel.id || session.isInside(channel.id, d.id)) return null;
const box = row.getBoundingClientRect();
const y = (e.clientY - box.top) / box.height;
if (y < 0.34) return 'before';
if (y > 0.66) return 'after';
if (into) return 'into';
return y < 0.5 ? 'before' : 'after';
}
// Resting on a closed channel opens it, to reach the channels inside
let openTimer: ReturnType<typeof setTimeout> | undefined;
function setZone(z: Zone | null) {
if (z === zone) return;
zone = z;
clearTimeout(openTimer);
if (z === 'into' && !expanded && children.length) openTimer = setTimeout(() => { if (zone === 'into' && !expanded) session.toggleExpanded(channel.id); }, 700);
}
function onDragOver(e: DragEvent, fixed?: Zone) {
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
if (z) e.preventDefault();
setZone(z);
}
// Leaving for an element inside the same target is not leaving
function onDragLeave(e: DragEvent) {
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) setZone(null);
}
function onDrop(e: DragEvent, fixed?: Zone) {
e.preventDefault();
// Work out the target before clearing: it depends on the dragged item
const d = drag.item;
const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement);
setZone(null);
drag.item = null;
if (!d || !ok) return;
const client = session.client;
if (!d || !z || !client) 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');
return;
}
if (z === 'into') {
client.updateChannel(d.id, { parent: channel.id });
} else {
const moved = session.channel(d.id);
if (!moved || channel.parent == null) return;
const rest = session.childrenOf(channel.parent).filter(c => c.id !== d.id);
const plan = planMove(rest, moved, rest.findIndex(c => c.id === channel.id) + (z === 'after' ? 1 : 0));
const own = plan.find(u => u.id === d.id);
const reparent = moved.parent !== channel.parent;
if (!reparent && !plan.length) return;
if (reparent || own) client.updateChannel(d.id, { ...(reparent ? { parent: channel.parent } : {}), ...(own ? { position: own.position } : {}) });
for (const u of plan) if (u.id !== d.id) client.updateChannel(u.id, { position: u.position });
}
sounds.play('channelMoved');
}
const channelClicks = clickOrDouble(
const openText = () => { session.selectView(`ch:${channel.id}`); onnavigate(); };
const channelClicks = $derived(isText ? { onclick: openText } : clickOrDouble(
() => { session.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); }
);
));
</script>
<li>
<div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px"
<!-- Behind an open channel is below its people and subchannels; the line is drawn there -->
<li class:after-end={zone === 'after' && open} style:--indent="{8 + depth * 14}px">
<div class="row channel" class:here class:selected class:dragging class:into={zone === 'into'} class:before={zone === 'before'} class:after={zone === 'after' && !open} class:target={zone === 'after' && open}
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}
ondragover={onDragOver}
ondragleave={onDragLeave}
ondrop={onDrop} bind:this={rowEl}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
@@ -128,11 +203,11 @@
<span class="caret"></span>
{/if}
<!-- 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}
<button class="name" class:text={isText} title={isText ? 'Text channel: click to open' : 'Click for details, double-click to join'} {...channelClicks}
draggable={channel.id !== 0}
ondragstart={e => { clearTimeout(hoverTimer); preview = null; 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}
ondragend={() => { drag.item = null; setZone(null); }}>
{#if isText}<Icon name="hash" size={14} />{:else 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}
{#if unread}<span class="badge">{unread}</span>{/if}
@@ -151,7 +226,9 @@
{/if}
{#if users.length && expanded}
<ul class="users">
<!-- The people under a channel belong to its drop target, so there is more to aim at -->
<ul class="users" class:into={zone === 'into'} role="presentation"
ondragover={e => onDragOver(e, 'into')} ondragleave={onDragLeave} ondrop={e => onDrop(e, 'into')}>
{#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble(
@@ -208,8 +285,20 @@
.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); }
/* Drop targets: the whole row for "into", a line with a square end for "in front of" and "behind" */
.channel { position: relative; }
.channel.dragging { opacity: 0.4; }
.channel.into { background: var(--accent); color: var(--accent-text); outline: 2px solid var(--accent-hover); outline-offset: -2px; }
.channel.into .label { color: var(--accent-text); }
.users.into { background: var(--bg-active); box-shadow: inset 3px 0 0 var(--accent); }
li.after-end { position: relative; }
.channel.before::before, .channel.after::before, li.after-end::before { content: ''; position: absolute; z-index: 2; left: var(--indent); right: 4px; height: 4px; background: var(--accent); pointer-events: none; }
.channel.before::after, .channel.after::after, li.after-end::after { content: ''; position: absolute; z-index: 2; left: calc(var(--indent) - 4px); width: 12px; height: 12px; background: var(--accent); pointer-events: none; }
.channel.before::before { top: -2px; }
.channel.before::after { top: -6px; }
.channel.after::before, li.after-end::before { bottom: -2px; }
.channel.after::after, li.after-end::after { bottom: -6px; }
.channel.before, .channel.after, .channel.target { background: var(--bg-hover); color: var(--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); }
+2 -1
View File
@@ -21,6 +21,7 @@
const logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
const isDm = $derived(session.view.startsWith('dm:'));
const isText = $derived(!isDm && session.isText(Number(session.view.slice(3))));
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return '';
@@ -71,7 +72,7 @@
<div class="empty">
{#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2>
<p>{isDm ? 'Messages here are only visible to the two of you.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
<p>{isDm ? 'Messages here are only visible to the two of you.' : isText ? 'A text channel: everyone on this server who uses mumh5 can read and write here, from any channel. Other Mumble clients do not see these messages.' : 'Messages go to everyone in this channel. Drop files here to share them.'}</p>
{:else if session.status === 'connecting'}
<h2>Connecting...</h2>
{:else}
+1 -1
View File
@@ -32,7 +32,7 @@
const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.author === b.author && a.self === b.self && b.time - a.time < 5 * 60000;
};
</script>
+3 -1
View File
@@ -89,7 +89,9 @@
{/if}
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
<div class="actions">
{#if session.self?.channelId === channel.id}
{#if session.isText(channel.id)}
<button class="btn primary" onclick={() => { session.selectView(`ch:${channel.id}`); onnavigate(); }}>Open text channel</button>
{:else if session.self?.channelId === channel.id}
<span class="here">You are in this channel</span>
{:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>