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
+3
View File
@@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Added ### Added
- Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them.
- Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it.
- Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there. - Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there.
- Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button. - Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button.
- Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles. - Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles.
@@ -28,6 +30,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
### Fixed ### Fixed
- A description preview could open after dropping a channel and stay on screen.
- Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages. - Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages.
## 0.1.0 ## 0.1.0
+4 -1
View File
@@ -50,6 +50,8 @@ A reused test server keeps registrations and channels from earlier runs; tests m
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback). - Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
- Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:<id>` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`.
Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns. Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns.
## Conventions ## Conventions
@@ -79,7 +81,8 @@ Voice runs on one server at a time (where you last joined a channel); background
- Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`. - Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`.
- 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames. - 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames.
- Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob. - Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob.
- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it; keep packets at 900 bytes and few. - Murmur drops PluginDataTransmission over 1000 bytes and rate limits it (measured: burst 15, then 4 per second, per sender, dropped silently); keep packets at 900 bytes and few.
- Murmur gives the id of a removed channel to the next one created; anything kept per channel id must be dropped on ChannelRemove.
- Renaming while connected is not possible; mumh5 reconnects with the new name. - Renaming while connected is not possible; mumh5 reconnects with the new name.
- Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything. - Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything.
- In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`. - In ACLs, Write overrides denies. Grant test rights to one user (`$<certhash>` group), not `@all`.
+3 -1
View File
@@ -41,7 +41,7 @@ mumh5 keeps the foundation and replaces the experience.
| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 | | Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 |
| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor | | Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor |
**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below. **Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. Push-to-talk and whisper are on the roadmap below.
--- ---
@@ -281,6 +281,8 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
- System-wide push to talk - System-wide push to talk
- Whisper and shout - Whisper and shout
- Text channels: private ones, and history while nobody is online (needs a helper on the server)
- A second, mobile-first interface with the same features in a different layout
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Rich chat between mumh5 users: replies, reactions, edits, typing indicators
- Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8) - Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8)
- Release builds for all platforms - Release builds for all platforms
+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] // 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])]; if (text == null) return [new Uint8Array([type])];
const data = await deflate(new TextEncoder().encode(text)); const data = await deflate(new TextEncoder().encode(text));
const total = Math.max(1, Math.ceil(data.length / CHUNK)); 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 // 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 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> { constructor(types: readonly T[]) {
const type = packet[0] as ShareType; this.types = types;
if (!Object.values(SHARE).includes(type)) return null; }
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 === 1) return { type };
if (packet.length < 4) return null; if (packet.length < 4) return null;
const [, id, index, total] = packet; const [, id, index, total] = packet;
@@ -95,3 +103,9 @@ export class ShareAssembler {
return plain ? { type, text: new TextDecoder().decode(plain) } : null; 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[] {
}) })
}); });
} }
return [ // A text channel is opened, not joined
{ header: true, label: c.name || 'Root' }, 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 }, { 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 // 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; } }, { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }
];
return [
{ header: true, label: c.name || 'Root' },
...open,
{ label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } },
{ separator: true, label: '' }, { separator: true, label: '' },
{ label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 }, { label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 },
+3
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 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 getBlob = (key: string) => run<Blob | undefined>('readonly', s => s.get(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)); 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 { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts';
import { native, openTransport } from './native.ts'; import { native, openTransport } from './native.ts';
import { share, type ShareKind } from './share.svelte.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 type { ServerCertInfo } from '../core/transport.ts';
import { store, type SavedServer } from './settings.svelte.ts'; import { store, type SavedServer } from './settings.svelte.ts';
import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.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) // 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 { isInside(channelId: number, ancestor: number): boolean {
let c = this.client?.channels.get(channelId); let c = this.client?.channels.get(channelId);
while (c && c.parent != null) { while (c && c.parent != null) {
@@ -292,6 +300,7 @@ export class Session {
this.client = null; this.client = null;
client?.disconnect(); client?.disconnect();
share.closed(this); share.closed(this);
textChannels.closed(this);
this.sharing = {}; this.sharing = {};
sessions.onClosed(this); sessions.onClosed(this);
this.status = 'idle'; this.status = 'idle';
@@ -318,11 +327,17 @@ export class Session {
for (const id of client.channels.keys()) client.requestPermissions(id); for (const id of client.channels.keys()) client.requestPermissions(id);
if (client.welcomeText) this.system(this.view, client.welcomeText, true); if (client.welcomeText) this.system(this.view, client.welcomeText, true);
share.entered(this); share.entered(this);
textChannels.entered(this);
bump(); 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 => { client.on('channel', ch => {
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id); if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
if (live() && client.synced) textChannels.channelChanged(this, ch.id);
bump(); bump();
}); });
client.on('permissions', () => { client.on('permissions', () => {
@@ -334,6 +349,11 @@ export class Session {
client.on('channelRemove', id => { client.on('channelRemove', id => {
if (!live()) return; if (!live()) return;
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' }; 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(); bump();
}); });
client.on('ping', rtt => { if (live()) this.rtt = rtt; }); client.on('ping', rtt => { if (live()) this.rtt = rtt; });
@@ -378,6 +398,7 @@ export class Session {
} }
const self = client.self; const self = client.self;
share.userMoved(this, user, true); share.userMoved(this, user, true);
textChannels.userLeft(this, user.session);
if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) { if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) {
const byOther = actor != null && actor !== user.session; const byOther = actor != null && actor !== user.session;
sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected'); sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected');
@@ -427,6 +448,7 @@ export class Session {
const kind = this.disconnectInfo?.kind; const kind = this.disconnectInfo?.kind;
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected'); sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
this.client = null; this.client = null;
textChannels.closed(this);
sessions.onClosed(this); sessions.onClosed(this);
this.status = 'idle'; this.status = 'idle';
this.error = reason === 'Disconnected' ? '' : reason; this.error = reason === 'Disconnected' ? '' : reason;
@@ -519,11 +541,27 @@ export class Session {
// ─── Chat ─────────────────────────────────────────────────────────────────── // ─── 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 // Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive
if (!this.logs[key]) this.logs[key] = []; if (!this.logs[key]) this.logs[key] = [];
this.logs[key].push({ ...entry, id: nextId++, time: Date.now() }); const log = this.logs[key];
if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1; 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 { private system(key: string, html: string, trusted = false): void {
@@ -592,6 +630,16 @@ export class Session {
sendHtml(html: string, key: string = this.view): boolean { sendHtml(html: string, key: string = this.view): boolean {
const client = this.client; const client = this.client;
if (!client || this.status !== 'connected') return false; 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; const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
if (limit && html.length > limit) { if (limit && html.length > limit) {
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`); 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); const problem = checkFile(info, f);
if (problem) this.system(key, escapeHtml(problem)); 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))) { for (const file of files.filter(f => !checkFile(info, f))) {
const job = { id: nextId++, key, name: file.name, progress: 0, error: '' }; const job = { id: nextId++, key, name: file.name, progress: 0, error: '' };
this.uploads.push(job); 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)})`; const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
let html = link; let html = link;
const cfg = this.client?.config; 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 budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
const thumb = await makeThumbnail(file, budget); const thumb = await makeThumbnail(file, budget);
if (thumb) html += `<br/><img src="${thumb}"/>`; 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. // scaled to the server's image limit. Other files need an upload host.
private async sendInlineImages(files: File[], key: string): Promise<void> { private async sendInlineImages(files: File[], key: string): Promise<void> {
const cfg = this.client?.config; const cfg = this.client?.config;
const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3)));
for (const file of files) { for (const file of files) {
if (!file.type.startsWith('image/')) { 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.`); 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; continue;
} }
if (cfg?.allowHtml === false) { if (cfg?.allowHtml === false && !textKey) {
this.system(key, 'This server does not allow images in messages.'); this.system(key, 'This server does not allow images in messages.');
return; return;
} }
// 0 means no limit; stay reasonable anyway // 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); const uri = await makeInlineImage(file, limit - 64);
if (!uri) { if (!uri) {
this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`); 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 { session } from '../lib/session.svelte.ts';
import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts'; import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts';
import { sounds } from '../lib/audio/sounds.svelte.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 = type Props =
| { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined } | { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined }
@@ -30,7 +33,11 @@
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const initial = channelId != null ? session.channel(channelId) : undefined; const initial = channelId != null ? session.channel(channelId) : undefined;
let name = $state(initial?.name ?? ''); 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 position = $state(initial?.position ?? 0);
let maxUsers = $state(initial?.maxUsers ?? 0); let maxUsers = $state(initial?.maxUsers ?? 0);
let temporary = $state(false); let temporary = $state(false);
@@ -43,7 +50,7 @@
let descLoaded = $state(!(initial?.descriptionHash && !initial?.description)); let descLoaded = $state(!(initial?.descriptionHash && !initial?.description));
$effect(() => { $effect(() => {
if (channelId != null && descPending) session.loadDescription(channelId); 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 ──────────────────────────────────────────────────────────────────── // ─── ACL ────────────────────────────────────────────────────────────────────
@@ -217,7 +224,7 @@
error = ''; error = '';
if (mode === 'create') { if (mode === 'create') {
if (!name.trim()) { error = 'The channel needs a name.'; return; } 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'); sounds.play('channelCreated');
return onclose(); return onclose();
} }
@@ -225,7 +232,9 @@
if (!ch || channelId == null) return onclose(); if (!ch || channelId == null) return onclose();
const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {}; const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {};
if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim(); 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 (position !== ch.position) fields.position = Number(position) || 0;
if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0; if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0;
if (Object.keys(fields).length) client.updateChannel(channelId, fields); 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> <label class="check"><input type="checkbox" bind:checked={temporary} disabled={!canTemp || !canPermanent} /> Temporary (removed when empty)</label>
{/if} {/if}
</div> </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> <p class="dim small">Lower positions come first. A user limit of 0 uses the server default.</p>
{:else if !acl} {:else if !acl}
<p class="dim">Loading permissions...</p> <p class="dim">Loading permissions...</p>
+116 -27
View File
@@ -14,6 +14,8 @@
import { share } from '../lib/share.svelte.ts'; import { share } from '../lib/share.svelte.ts';
import { sessions } from '../lib/session.svelte.ts'; import { sessions } from '../lib/session.svelte.ts';
import type { Channel } from '../core/client.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(); let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
@@ -21,12 +23,14 @@
const children = $derived(session.childrenOf(channel.id)); const children = $derived(session.childrenOf(channel.id));
const expanded = $derived(session.isExpanded(channel.id)); const expanded = $derived(session.isExpanded(channel.id));
const here = $derived(session.self?.channelId === 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 unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
const total = $derived(session.subtreeUserCount(channel.id)); 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 // 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 : ''); const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
let preview = $state<{ x: number; y: number } | null>(null); let preview = $state<{ x: number; y: number } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined; let hoverTimer: ReturnType<typeof setTimeout> | undefined;
@@ -40,6 +44,8 @@
trackPointer(e); trackPointer(e);
clearTimeout(hoverTimer); clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => { 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 // Long descriptions are only announced by hash; the text is fetched now
session.loadDescription(channel.id); session.loadDescription(channel.id);
// Just below and right of the pointer, kept inside the window // 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 // An image opened from the preview covers it; the preview stays underneath
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250); 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 // Back from the image: the preview stays while the pointer is on it, and goes when it is not
let popEl = $state<HTMLElement>(); let popEl = $state<HTMLElement>();
let hadImage = false; 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 // Drop a person to move them here. A channel dropped on the middle of a row becomes a
let over = $state(false); // subchannel; dropped on the upper or lower third it goes in front of or behind this one.
const canDrop = $derived.by(() => { 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; const d = drag.item;
if (!d) return false; if (!d) return false;
if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id; 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); return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id);
}); }
function onDrop(e: DragEvent) { function zoneAt(e: DragEvent, row: HTMLElement): Zone | null {
e.preventDefault();
over = false;
// Check before clearing: canDrop is derived from the dragged item
const d = drag.item; 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; drag.item = null;
if (!d || !ok) return; const client = session.client;
if (!d || !z || !client) return;
if (d.kind === 'user') { if (d.kind === 'user') {
if (d.id === session.self?.session) session.joinChannel(channel.id); if (d.id === session.self?.session) session.joinChannel(channel.id);
else session.moveUser(d.id, channel.id); else session.moveUser(d.id, channel.id);
return;
}
if (z === 'into') {
client.updateChannel(d.id, { parent: channel.id });
} else { } else {
session.client?.updateChannel(d.id, { parent: channel.id }); 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'); 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.showChannel(channel.id); ui.panelOpen = true; },
() => { session.joinChannel(channel.id); onnavigate(); } () => { session.joinChannel(channel.id); onnavigate(); }
); ));
</script> </script>
<li> <!-- Behind an open channel is below its people and subchannels; the line is drawn there -->
<div class="row channel" class:here class:selected class:over style:padding-left="{8 + depth * 14}px" <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" oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation"
ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} ondragover={onDragOver}
ondragleave={() => (over = false)} ondragleave={onDragLeave}
ondrop={onDrop} ondrop={onDrop} bind:this={rowEl}
onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}> onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}>
{#if children.length || users.length} {#if children.length || users.length}
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)} <button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
@@ -128,11 +203,11 @@
<span class="caret"></span> <span class="caret"></span>
{/if} {/if}
<!-- The name is what people grab; a drag cannot start from a button inside a draggable element --> <!-- 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} draggable={channel.id !== 0}
ondragstart={e => { clearTimeout(hoverTimer); preview = null; drag.item = { kind: 'channel', id: channel.id }; e.dataTransfer?.setData('text/plain', channel.name); }} 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; }}> ondragend={() => { drag.item = null; setZone(null); }}>
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if} {#if isText}<Icon name="hash" size={14} />{:else if !channel.canEnter}<Icon name="lock" size={14} />{/if}
<span class="label">{channel.name || 'Root'}</span> <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 !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
{#if unread}<span class="badge">{unread}</span>{/if} {#if unread}<span class="badge">{unread}</span>{/if}
@@ -151,7 +226,9 @@
{/if} {/if}
{#if users.length && expanded} {#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)} {#each users as u (u.session)}
{@const isSelf = u.session === session.self?.session} {@const isSelf = u.session === session.self?.session}
{@const clicks = clickOrDouble( {@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); } .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:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
.channel.selected { background: var(--bg-active); color: var(--text); } .channel.selected { background: var(--bg-active); color: var(--text); }
.channel.over { background: var(--accent); color: var(--accent-text); } /* Drop targets: the whole row for "into", a line with a square end for "in front of" and "behind" */
.channel.over .label { color: var(--accent-text); } .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 .label { color: var(--text); font-weight: 600; }
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); } .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); } .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 logFixed = $derived(streamAbove && logHeight >= LOG_MIN);
const isDm = $derived(session.view.startsWith('dm:')); 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 title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
const topic = $derived.by(() => { const topic = $derived.by(() => {
if (isDm || session.status !== 'connected') return ''; if (isDm || session.status !== 'connected') return '';
@@ -71,7 +72,7 @@
<div class="empty"> <div class="empty">
{#if session.status === 'connected'} {#if session.status === 'connected'}
<h2>{isDm ? `Direct messages with ${title}` : `Welcome to #${title}`}</h2> <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'} {:else if session.status === 'connecting'}
<h2>Connecting...</h2> <h2>Connecting...</h2>
{:else} {:else}
+1 -1
View File
@@ -32,7 +32,7 @@
const grouped = (i: number) => { const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i]; 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> </script>
+3 -1
View File
@@ -89,7 +89,9 @@
{/if} {/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> <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"> <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> <span class="here">You are in this channel</span>
{:else} {:else}
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}> <button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
+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 type { Transport } from '../../src/core/transport.ts';
import { openTls } from '../../electron/tls-transport.ts'; import { openTls } from '../../electron/tls-transport.ts';
import { generateIdentity } from '../../electron/identity.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; const target = process.env.MUMBLE_TEST_HOST;
if (!target) { if (!target) {
@@ -70,6 +72,8 @@ const app = await electron.launch({
const bob = await headless('bob'); const bob = await headless('bob');
// SuperUser can create channels; needs MUMBLE_SUPERUSER_PASSWORD on the test server // 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; 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 { try {
const page = await app.firstWindow(); const page = await app.firstWindow();
page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); }); page.on('console', m => { if (m.type() === 'error') console.log('[renderer]', m.text()); });
@@ -839,6 +843,28 @@ try {
bob.joinChannel(0); bob.joinChannel(0);
console.log('ok: drag and drop moves channels and people'); 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) // 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', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' }); 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'); assert.notEqual(await page.locator('.me .who span').textContent(), 'Root');
await page.locator('.sidebar .row.user.self').waitFor(); await page.locator('.sidebar .row.user.self').waitFor();
console.log('ok: toolbar expands and collapses all channels, occupied ones stay open'); 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 // Without an upload host, images are sent inline within Mumble's image limit
@@ -983,6 +1087,18 @@ try {
}), 'bob sees the new name'); }), 'bob sees the new name');
console.log('ok: name changed by reconnecting'); 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 // Self-registration from the right-click menu on yourself; the profile shows no badge either way
const meRow = page.locator('.sidebar .user.self'); const meRow = page.locator('.sidebar .user.self');
await meRow.click({ button: 'right' }); 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');
});