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
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { planMove, byOrder, type Ordered } from '../src/core/channel-order.ts';
const ch = (id: number, name: string, position = 0): Ordered => ({ id, name, position });
// The order everyone sees once the plan is applied
function after(siblings: Ordered[], moved: Ordered, index: number): string[] {
const plan = planMove(siblings, moved, index);
const all = [...siblings.filter(c => c.id !== moved.id), moved].map(c => ({ ...c, position: plan.find(u => u.id === c.id)?.position ?? c.position }));
return all.sort(byOrder).map(c => c.name);
}
test('a spot that the name already gives needs no change', () => {
const list = [ch(1, 'Alpha'), ch(3, 'Gamma')];
assert.deepEqual(planMove(list, ch(2, 'Beta'), 1), []);
});
test('one change when there is room between the neighbours', () => {
const list = [ch(1, 'Alpha', 0), ch(2, 'Beta', 5), ch(3, 'Gamma', 9)];
const plan = planMove(list, ch(3, 'Gamma', 9), 1);
assert.equal(plan.length, 1);
assert.deepEqual(after(list, ch(3, 'Gamma', 9), 1), ['Alpha', 'Gamma', 'Beta']);
});
test('to the front and to the end', () => {
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma')];
assert.deepEqual(planMove(list, list[2], 0), [{ id: 3, position: -1 }]);
assert.deepEqual(planMove(list, list[0], 2), [{ id: 1, position: 1 }]);
});
test('channels that all share a position are numbered again when the name does not fit', () => {
const list = [ch(1, 'Alpha'), ch(2, 'Beta'), ch(3, 'Gamma'), ch(4, 'Delta')];
// Sorted: Alpha, Beta, Delta, Gamma. Gamma between Alpha and Beta.
assert.deepEqual(after(list, list[2], 1), ['Alpha', 'Gamma', 'Beta', 'Delta']);
assert.ok(planMove(list, list[2], 1).length > 1);
});
test('every spot works, for a channel of the row and for one coming from elsewhere', () => {
const list = [ch(1, 'b', 0), ch(2, 'a', 0), ch(3, 'c', 1), ch(4, 'd', 1), ch(5, 'e', 7)];
const sorted = [...list].sort(byOrder);
for (const moved of [...list, ch(9, 'new', 1)]) {
const rest = sorted.filter(c => c.id !== moved.id).map(c => c.name);
for (let i = 0; i <= rest.length; i++) {
assert.deepEqual(after(list, moved, i), [...rest.slice(0, i), moved.name, ...rest.slice(i)], `${moved.name} to ${i}`);
}
}
});
+116
View File
@@ -14,6 +14,8 @@ import { MumbleClient } from '../../src/core/client.ts';
import type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.ts';
import { byOrder } from '../../src/core/channel-order.ts';
import { TEXT, TEXT_DATA_ID, TEXT_MARK, TextAssembler, encodeText, parseMsg, parseHistory } from '../../src/core/text-signal.ts';
const target = process.env.MUMBLE_TEST_HOST;
if (!target) {
@@ -70,6 +72,8 @@ const app = await electron.launch({
const bob = await headless('bob');
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server
const admin = process.env.MUMBLE_SUPERUSER_PASSWORD ? await headless('SuperUser', process.env.MUMBLE_SUPERUSER_PASSWORD) : null;
// The text channel made further down; checked again after the app reconnects
let notesId: number | null = null;
try {
const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
@@ -839,6 +843,28 @@ try {
bob.joinChannel(0);
console.log('ok: drag and drop moves channels and people');
// Dropped on the lower or upper third of a row, a channel goes behind or in front of it.
// A line shows where it will land.
const topOrder = () => [...bob.channels.values()].filter(c => c.parent === 0).sort(byOrder).map(c => c.name);
assert.deepEqual(topOrder(), ['Games', 'Lobby']);
await page.locator('.channel .name', { hasText: 'Games' }).hover();
await page.mouse.down();
const lobbyBox = (await page.locator('.channel', { hasText: 'Lobby' }).first().boundingBox())!;
await page.mouse.move(lobbyBox.x + 60, lobbyBox.y + lobbyBox.height - 4, { steps: 8 });
// Lobby is open (people inside), so the line sits below them
await page.locator('li.after-end', { hasText: 'Lobby' }).waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'drag-line.png') });
assert.equal(await page.locator('.channel.dragging', { hasText: 'Games' }).count(), 1, 'the dragged channel is dimmed');
await page.mouse.up();
await bobSees('Games behind Lobby', () => topOrder().join() === 'Lobby,Games');
assert.equal(await page.locator('li.after-end, .channel.after, .channel.before, .channel.into').count(), 0);
await page.locator('.channel .name', { hasText: 'Games' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first(), { targetPosition: { x: 60, y: 3 } });
await bobSees('Games in front of Lobby again', () => topOrder().join() === 'Games,Lobby');
// The pointer rests on a row with a description; move away so no preview covers the list
await page.mouse.move(700, 400);
await page.locator('.desc-pop').waitFor({ state: 'detached' });
console.log('ok: drag and drop puts a channel in front of or behind another, with a line where it lands');
// Delete (Team Room is now inside Games, which stays collapsed while empty)
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
@@ -856,6 +882,84 @@ try {
assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
await page.locator('.sidebar .row.user.self').waitFor();
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open');
// ─── Text channels: read and written without joining, between mumh5 clients ───
// Bob plays a second mumh5 client by speaking the plugin data protocol
const assembler = new TextAssembler();
const signals: { type: number; text?: string }[] = [];
const waiting: { want: (s: { type: number; text?: string }) => boolean; res: (s: { type: number; text?: string }) => void }[] = [];
const bobSend = async (to: number[], type: Parameters<typeof encodeText>[0], body?: unknown) => {
for (const p of await encodeText(type, body)) { bob.sendPluginData(to, TEXT_DATA_ID, p); await new Promise(r => setTimeout(r, 120)); }
};
bob.on('pluginData', async (sender, dataId, data) => {
if (dataId !== TEXT_DATA_ID) return;
const signal = await assembler.push(sender, data);
if (!signal) return;
// Like a mumh5 client that was here before: answers a newcomer and hands over an old message
if (signal.type === TEXT.hello) bobSend([sender], TEXT.here);
if (signal.type === TEXT.want && notesId != null) {
bobSend([sender], TEXT.history, { c: notesId, m: [{ i: 'from-before', t: Date.now() - 3600_000, n: 'dave', k: '', h: 'said while you were away' }] });
}
signals.push(signal);
const at = waiting.findIndex(w => w.want(signal));
if (at >= 0) waiting.splice(at, 1)[0].res(signal);
});
const bobGets = (label: string, want: (s: { type: number; text?: string }) => boolean) =>
within(new Promise<{ type: number; text?: string }>(res => waiting.push({ want, res })), label);
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
await page.getByLabel('Name').fill('notes');
await page.getByLabel('Text channel (mumh5 only)').check();
await page.getByRole('button', { name: 'Create channel' }).click();
await bobSees('text channel', () => !!chByName('notes')?.description.startsWith(TEXT_MARK));
notesId = chByName('notes')!.id;
const notesRow = page.locator('.channel', { hasText: 'notes' });
// No description marker for a channel whose description is only the text channel mark
assert.equal(await notesRow.locator('.desc').count(), 0);
const voiceChannel = await page.locator('.me .who span').textContent();
await notesRow.locator('.name').click();
await page.locator('.chat h1', { hasText: 'notes' }).waitFor();
assert.equal(await page.locator('.me .who span').textContent(), voiceChannel, 'opening a text channel does not join it');
const sent = bobGets('text channel message', m => m.type === TEXT.msg);
await page.locator('.chat .composer [contenteditable]').fill('text hello');
await page.locator('.chat .composer [contenteditable]').press('Enter');
const got = parseMsg((await sent).text);
assert.equal(got?.c, notesId);
assert.equal(got?.h, 'text hello');
await page.locator('.chat .msg', { hasText: 'text hello' }).waitFor();
console.log('ok: text channel created, opened without joining, message reaches others');
// A message from someone in another channel arrives; unread while another chat is open
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-1', c: notesId, h: 'hello from <b>bob</b>' });
await page.locator('.chat .msg', { hasText: 'hello from bob' }).locator('strong', { hasText: 'bob' }).first().waitFor();
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
await notesRow.locator('.badge', { hasText: '1' }).waitFor();
// The same message again is not shown twice
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-2', c: notesId, h: 'second from bob' });
await page.waitForTimeout(500);
await notesRow.locator('.name').click();
await page.locator('.chat .msg', { hasText: 'second from bob' }).waitFor();
assert.equal(await page.locator('.chat .html', { hasText: 'second from bob' }).count(), 1);
assert.equal(await notesRow.locator('.badge').count(), 0);
// Not into ordinary channels
await bobSend([aliceSession()], TEXT.msg, { i: 'bob-3', c: gamesId, h: 'sneaked in' });
await page.waitForTimeout(500);
assert.equal(await page.locator('.channel', { hasText: 'Games' }).first().locator('.badge').count(), 0);
console.log('ok: text channel messages arrive, count as unread, no duplicates, only in text channels');
// The app hands its messages to a client that asks
const handed = bobGets('history', m => m.type === TEXT.history && parseHistory(m.text).length > 0);
await bobSend([aliceSession()], TEXT.want);
const history = parseHistory((await handed).text);
assert.deepEqual(history.map(m => m.h), ['text hello', 'hello from <b>bob</b>', 'second from bob']);
assert.deepEqual(history.map(m => m.n), ['alice', 'bob', 'bob']);
assert.equal(history[0].c, notesId);
await page.locator('.channel .name', { hasText: voiceChannel! }).first().dblclick();
await page.locator('.chat h1', { hasText: voiceChannel! }).waitFor();
console.log('ok: text channel messages handed to a client that asks');
}
// Without an upload host, images are sent inline within Mumble's image limit
@@ -983,6 +1087,18 @@ try {
}), 'bob sees the new name');
console.log('ok: name changed by reconnecting');
// Text channel after reconnecting: what this computer kept, plus what another client hands over
if (notesId != null) {
await page.locator('.channel .name', { hasText: 'notes' }).click();
await page.locator('.chat .msg', { hasText: 'said while you were away' }).locator('strong', { hasText: 'dave' }).waitFor();
const texts = await page.locator('.chat .msg .html').allTextContents();
assert.deepEqual(texts.map(t => t.trim()).filter(t => !t.startsWith('You joined')), ['said while you were away', 'text hello', 'hello from bob', 'second from bob']);
assert.equal(await page.locator('.channel', { hasText: 'notes' }).locator('.badge').count(), 0, 'old messages are not unread');
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
console.log('ok: text channel keeps its messages and catches up from another client');
}
// Self-registration from the right-click menu on yourself; the profile shows no badge either way
const meRow = page.locator('.sidebar .user.self');
await meRow.click({ button: 'right' });
+84
View File
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TEXT, TEXT_MARK, HAND_OVER, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, isTextChannel, markText, stripMark, newId, type TextMsg } from '../src/core/text-signal.ts';
const msg = (i: string, t: number, h = 'hi'): TextMsg => ({ i, c: 3, t, n: 'ann', k: 'abc', h });
test('the marker turns a description into a text channel and comes off again', () => {
assert.equal(isTextChannel('<b>Rules</b>'), false);
const marked = markText('<b>Rules</b>', true);
assert.equal(marked, TEXT_MARK + '<b>Rules</b>');
assert.equal(isTextChannel(marked), true);
// Marking twice leaves one marker
assert.equal(markText(marked, true), marked);
assert.equal(markText(marked, false), '<b>Rules</b>');
assert.equal(stripMark(marked), '<b>Rules</b>');
});
test('a message survives the trip, in packets Murmur accepts', async () => {
const body = { i: newId(), c: 7, h: '<b>hello</b> ' + 'x'.repeat(3000) };
const packets = await encodeText(TEXT.msg, body);
assert.ok(packets.every(p => p.length <= 1000));
const assembler = new TextAssembler();
let signal = null;
for (const p of packets) signal = await assembler.push(1, p);
assert.equal(signal?.type, TEXT.msg);
assert.deepEqual(parseMsg(signal?.text), body);
});
test('a short message is one packet', async () => {
assert.equal((await encodeText(TEXT.msg, { i: newId(), c: 1, h: 'How is everyone doing today?' })).length, 1);
});
test('malformed messages are refused', () => {
assert.equal(parseMsg('nope'), null);
assert.equal(parseMsg('[]'), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: -1, h: 'x' })), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: '' })), null);
assert.equal(parseMsg(JSON.stringify({ i: '', c: 1, h: 'x' })), null);
assert.equal(parseMsg(JSON.stringify({ i: 'a', c: 1, h: 'x'.repeat(MAX_HTML + 1) })), null);
});
test('history keeps valid entries, limits their number and pulls future times back', () => {
const now = 1_000_000;
const m = [
{ i: 'a', t: 500, n: 'ann', k: 'h1', h: 'one' },
{ i: 'b', t: now + 99999, n: 'bob', k: '', h: 'two' },
{ i: 'c', t: 'soon', n: 'eve', k: '', h: 'bad time' },
{ i: 'd', t: 600, n: 'eve', k: '', h: '' },
null
];
assert.deepEqual(parseHistory(JSON.stringify({ c: 4, m }), now), [
{ i: 'a', c: 4, t: 500, n: 'ann', k: 'h1', h: 'one' },
{ i: 'b', c: 4, t: now, n: 'bob', k: '', h: 'two' }
]);
const many = Array.from({ length: HAND_OVER + 30 }, (_, i) => ({ i: `m${i}`, t: i, n: 'ann', k: '', h: 'x' }));
assert.equal(parseHistory(JSON.stringify({ c: 4, m: many }), now).length, HAND_OVER);
assert.deepEqual(parseHistory('{"c":4}', now), []);
});
test('merging adds only unknown messages, in order of time, and keeps the newest', () => {
const have = [msg('a', 10), msg('c', 30)];
const { all, added } = mergeMsgs(have, [msg('b', 20), msg('a', 10), msg('d', 40)]);
assert.deepEqual(all.map(m => m.i), ['a', 'b', 'c', 'd']);
assert.deepEqual(added.map(m => m.i), ['b', 'd']);
assert.equal(mergeMsgs(have, [msg('a', 10)]).added.length, 0);
// Over the limit the oldest go, and an old message that does not make it is not reported as added
const kept = mergeMsgs([msg('x', 50), msg('y', 60)], [msg('old', 1), msg('z', 70)], 2);
assert.deepEqual(kept.all.map(m => m.i), ['y', 'z']);
assert.deepEqual(kept.added.map(m => m.i), ['z']);
});
test('history that is too large for one signal is cut down to the newest messages', async () => {
// Incompressible text, so 50 messages do not fit
const noise = (seed: number) => Array.from({ length: 1500 }, (_, i) => ((i + 1) * (seed + 7) * 2654435761 >>> 0).toString(36)).join('');
const msgs = Array.from({ length: 50 }, (_, i) => msg(`m${i}`, i, noise(i)));
const packets = await encodeHistory(3, msgs);
assert.ok(packets.length > 0 && packets.length <= 32);
const assembler = new TextAssembler();
let signal = null;
for (const p of packets) signal = await assembler.push(1, p);
const back = parseHistory(signal?.text);
assert.ok(back.length > 0 && back.length < 50);
assert.equal(back[back.length - 1].i, 'm49');
});