diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cae517..76636f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### 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. - 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. @@ -28,6 +30,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### 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. ## 0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 08beab0..b12e600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). - `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:` 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. ## 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()`. - 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. -- 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. - 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 (`$` group), not `@all`. diff --git a/README.md b/README.md index bba8ffa..f603097 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 - 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 - Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8) - Release builds for all platforms diff --git a/src/core/channel-order.ts b/src/core/channel-order.ts new file mode 100644 index 0000000..ccb1727 --- /dev/null +++ b/src/core/channel-order.ts @@ -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); +} diff --git a/src/core/share-signal.ts b/src/core/share-signal.ts index 41b4d39..392998a 100644 --- a/src/core/share-signal.ts +++ b/src/core/share-signal.ts @@ -47,7 +47,7 @@ async function inflate(bytes: Uint8Array): Promise { } // 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 { +export async function encodeSignal(type: number, text?: string): Promise { if (text == null) return [new Uint8Array([type])]; const data = await deflate(new TextEncoder().encode(text)); const total = Math.max(1, Math.ceil(data.length / CHUNK)); @@ -62,15 +62,23 @@ export async function encodeShare(type: ShareType, text?: string): Promise encodeSignal(type, text); + +export interface Signal { type: T; text?: string } +export type ShareSignal = Signal; // Puts chunked signals back together, per sender -export class ShareAssembler { +export class SignalAssembler { private pending = new Map(); + private types: readonly T[]; - async push(sender: number, packet: Uint8Array, now = Date.now()): Promise { - const type = packet[0] as ShareType; - if (!Object.values(SHARE).includes(type)) return null; + constructor(types: readonly T[]) { + this.types = types; + } + + async push(sender: number, packet: Uint8Array, now = Date.now()): Promise | null> { + const type = packet[0] as T; + if (!this.types.includes(type)) return null; if (packet.length === 1) return { type }; if (packet.length < 4) return null; const [, id, index, total] = packet; @@ -95,3 +103,9 @@ export class ShareAssembler { return plain ? { type, text: new TextDecoder().decode(plain) } : null; } } + +export class ShareAssembler extends SignalAssembler { + constructor() { + super(Object.values(SHARE)); + } +} diff --git a/src/core/text-signal.ts b/src/core/text-signal.ts new file mode 100644 index 0000000..95357e8 --- /dev/null +++ b/src/core/text-signal.ts @@ -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 = ''; +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 { + 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 | 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[]) { + 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 { + 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 []; +} diff --git a/src/lib/actions.ts b/src/lib/actions.ts index e50a02d..6afd86b 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -36,11 +36,17 @@ export function channelMenu(c: Channel): MenuItem[] { }) }); } + // A text channel is opened, not joined + const open: MenuItem[] = session.isText(c.id) + ? [{ label: 'Open text channel', action: () => session.selectView(`ch:${c.id}`) }] + : [ + { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, + // Opens the channel's chat in the side panel, ready to type, without joining it + { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } } + ]; return [ { header: true, label: c.name || 'Root' }, - { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, - // Opens the channel's chat in the side panel, ready to type, without joining it - { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }, + ...open, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { separator: true, label: '' }, { label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 }, diff --git a/src/lib/blobstore.ts b/src/lib/blobstore.ts index 876ec46..dbeac00 100644 --- a/src/lib/blobstore.ts +++ b/src/lib/blobstore.ts @@ -21,4 +21,7 @@ function run(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest< export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key)); export const getBlob = (key: string) => run('readonly', s => s.get(key)); -export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key)); +// Plain data that outgrows localStorage (text channel messages) +export const putData = (key: string, value: unknown) => run('readwrite', s => s.put(value, key)); +export const getData = (key: string) => run('readonly', s => s.get(key)); +export const deleteBlob =(key: string) => run('readwrite', s => s.delete(key)); diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 2f0bba0..1202b83 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -2,6 +2,8 @@ import { createCodec } from '../core/proto.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; import { native, openTransport } from './native.ts'; import { share, type ShareKind } from './share.svelte.ts'; +import { textChannels } from './text-channels.ts'; +import { MAX_HTML, type TextMsg } from '../core/text-signal.ts'; import type { ServerCertInfo } from '../core/transport.ts'; import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; @@ -188,6 +190,12 @@ export class Session { } // Whether a channel lies somewhere below another (a channel cannot move into its own subtree) + // A channel that is read and written without joining it (mumh5 clients only) + isText(channelId: number): boolean { + void this.tick; + return textChannels.isText(this, channelId); + } + isInside(channelId: number, ancestor: number): boolean { let c = this.client?.channels.get(channelId); while (c && c.parent != null) { @@ -292,6 +300,7 @@ export class Session { this.client = null; client?.disconnect(); share.closed(this); + textChannels.closed(this); this.sharing = {}; sessions.onClosed(this); this.status = 'idle'; @@ -318,11 +327,17 @@ export class Session { for (const id of client.channels.keys()) client.requestPermissions(id); if (client.welcomeText) this.system(this.view, client.welcomeText, true); share.entered(this); + textChannels.entered(this); bump(); }); - client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); }); + client.on('pluginData', (sender, dataId, data) => { + if (!live()) return; + share.receive(this, sender, dataId, data); + textChannels.receive(this, sender, dataId, data); + }); client.on('channel', ch => { if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id); + if (live() && client.synced) textChannels.channelChanged(this, ch.id); bump(); }); client.on('permissions', () => { @@ -334,6 +349,11 @@ export class Session { client.on('channelRemove', id => { if (!live()) return; if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' }; + textChannels.channelRemoved(this, id); + // The chat on screen is gone: back to the channel we are in + if (this.view === `ch:${id}`) this.view = `ch:${client.self?.channelId ?? 0}`; + delete this.logs[`ch:${id}`]; + delete this.unread[`ch:${id}`]; bump(); }); client.on('ping', rtt => { if (live()) this.rtt = rtt; }); @@ -378,6 +398,7 @@ export class Session { } const self = client.self; share.userMoved(this, user, true); + textChannels.userLeft(this, user.session); if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) { const byOther = actor != null && actor !== user.session; sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected'); @@ -427,6 +448,7 @@ export class Session { const kind = this.disconnectInfo?.kind; sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected'); this.client = null; + textChannels.closed(this); sessions.onClosed(this); this.status = 'idle'; this.error = reason === 'Disconnected' ? '' : reason; @@ -519,11 +541,27 @@ export class Session { // ─── Chat ─────────────────────────────────────────────────────────────────── - private push(key: string, entry: Omit): 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, opts: { time?: number; quiet?: boolean } = {}): void { // Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive if (!this.logs[key]) this.logs[key] = []; - this.logs[key].push({ ...entry, id: nextId++, time: Date.now() }); - if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1; + const log = this.logs[key]; + const time = opts.time ?? Date.now(); + let at = log.length; + while (at > 0 && log[at - 1].time > time) at--; + log.splice(at, 0, { ...entry, id: nextId++, time }); + if (!opts.quiet && (sessions.active !== this || !this.visibleKeys().includes(key))) this.unread[key] = (this.unread[key] ?? 0) + 1; + } + + // A text channel message: live, kept from earlier, or handed over by another client + addText(m: TextMsg, self: boolean, quiet: boolean, sender: number | null = null): void { + const client = this.client; + if (!client) return; + const online = sender != null ? client.users.get(sender) : self ? client.self : m.k ? [...client.users.values()].find(u => u.hash === m.k) : undefined; + this.push(`ch:${m.c}`, { + kind: 'text', author: online ? store.displayName(online) : m.n, authorSession: online?.session ?? null, self, + ...renderIncoming(m.h, store.trustedHosts) + }, { time: m.t, quiet }); } private system(key: string, html: string, trusted = false): void { @@ -592,6 +630,16 @@ export class Session { sendHtml(html: string, key: string = this.view): boolean { const client = this.client; if (!client || this.status !== 'connected') return false; + if (key.startsWith('ch:') && this.isText(Number(key.slice(3)))) { + if (html.length > MAX_HTML) { + this.system(key, `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`); + return false; + } + // Shown in the log by the text channel code once it is on its way + textChannels.post(this, Number(key.slice(3)), html).then(problem => { if (problem) this.system(key, escapeHtml(problem)); }); + sounds.play('messageSent'); + return true; + } const limit = / limit) { this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`); @@ -623,6 +671,7 @@ export class Session { const problem = checkFile(info, f); if (problem) this.system(key, escapeHtml(problem)); } + const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3))); for (const file of files.filter(f => !checkFile(info, f))) { const job = { id: nextId++, key, name: file.name, progress: 0, error: '' }; this.uploads.push(job); @@ -633,7 +682,8 @@ export class Session { const link = `${escapeHtml(res.name)} (${formatBytes(res.size)})`; let html = link; const cfg = this.client?.config; - if (sendThumbnails && cfg?.allowHtml !== false) { + // Text channels only reach mumh5, which shows the file itself instead of a thumbnail + if (sendThumbnails && cfg?.allowHtml !== false && !textKey) { const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64; const thumb = await makeThumbnail(file, budget); if (thumb) html += `
`; @@ -654,17 +704,18 @@ export class Session { // scaled to the server's image limit. Other files need an upload host. private async sendInlineImages(files: File[], key: string): Promise { const cfg = this.client?.config; + const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3))); for (const file of files) { if (!file.type.startsWith('image/')) { this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`); continue; } - if (cfg?.allowHtml === false) { + if (cfg?.allowHtml === false && !textKey) { this.system(key, 'This server does not allow images in messages.'); return; } // 0 means no limit; stay reasonable anyway - const limit = cfg?.imageMessageLength || 1024 * 1024; + const limit = textKey ? MAX_HTML : cfg?.imageMessageLength || 1024 * 1024; const uri = await makeInlineImage(file, limit - 64); if (!uri) { this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`); diff --git a/src/lib/text-channels.ts b/src/lib/text-channels.ts new file mode 100644 index 0000000..ab41b9a --- /dev/null +++ b/src/lib/text-channels.ts @@ -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; + // Channels whose kept messages are in the chat log already + shown: Set; + // 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 | undefined; + // Catching up: who answered our hello, who we asked, and whether that is settled + peers: number[]; + asked: number[]; + caughtUp: boolean; + askTimer: ReturnType | undefined; + saveTimer: ReturnType | undefined; + storeKey: string; +} + +class TextChannels { + private states = new WeakMap(); + + // 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 { + 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>(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 { + 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 { + 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 { + st.saveTimer = undefined; + try { + const kept = await getData>(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 { + try { + const kept = await getData>(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 { + 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(); diff --git a/src/ui/ChannelDialog.svelte b/src/ui/ChannelDialog.svelte index a8b8ceb..a3623a5 100644 --- a/src/ui/ChannelDialog.svelte +++ b/src/ui/ChannelDialog.svelte @@ -4,6 +4,9 @@ import { session } from '../lib/session.svelte.ts'; import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts'; + import { isTextChannel, markText, stripMark } from '../core/text-signal.ts'; + import { textChannels } from '../lib/text-channels.ts'; + import { sessions } from '../lib/session.svelte.ts'; type Props = | { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined } @@ -30,7 +33,11 @@ // svelte-ignore state_referenced_locally const initial = channelId != null ? session.channel(channelId) : undefined; let name = $state(initial?.name ?? ''); - let description = $state(initial?.description ?? ''); + // The editor never sees the text channel marker; it is put back when saving + let description = $state(stripMark(initial?.description ?? '')); + let textChannel = $state(isTextChannel(initial?.description ?? '')); + // The marker is an HTML comment and the messages are plugin data + const textPossible = textChannels.supported(sessions.active) && session.serverInfo?.config.allowHtml !== false; let position = $state(initial?.position ?? 0); let maxUsers = $state(initial?.maxUsers ?? 0); let temporary = $state(false); @@ -43,7 +50,7 @@ let descLoaded = $state(!(initial?.descriptionHash && !initial?.description)); $effect(() => { if (channelId != null && descPending) session.loadDescription(channelId); - if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; } + if (!descLoaded && channel?.description) { description = stripMark(channel.description); textChannel = isTextChannel(channel.description); descLoaded = true; } }); // ─── ACL ──────────────────────────────────────────────────────────────────── @@ -217,7 +224,7 @@ error = ''; if (mode === 'create') { if (!name.trim()) { error = 'The channel needs a name.'; return; } - client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers }); + client.createChannel(parentId!, name.trim(), { temporary, description: markText(description, textChannel), position, maxUsers }); sounds.play('channelCreated'); return onclose(); } @@ -225,7 +232,9 @@ if (!ch || channelId == null) return onclose(); const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {}; if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim(); - if (descLoaded && description !== ch.description) fields.description = description; + // An untouched description may not start with the marker; that alone is no change + const changed = description !== stripMark(ch.description) || textChannel !== isTextChannel(ch.description); + if (descLoaded && changed) fields.description = markText(description, textChannel); if (position !== ch.position) fields.position = Number(position) || 0; if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0; if (Object.keys(fields).length) client.updateChannel(channelId, fields); @@ -268,6 +277,12 @@ {/if} + {#if !isRoot} + + {#if !textPossible}

Text channels need a server from version 1.4 that allows HTML.

{/if} + {/if}

Lower positions come first. A user limit of 0 uses the server default.

{:else if !acl}

Loading permissions...

diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index 5912643..39a8c3a 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -14,6 +14,8 @@ import { share } from '../lib/share.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; import type { Channel } from '../core/client.ts'; + import { stripMark } from '../core/text-signal.ts'; + import { planMove } from '../core/channel-order.ts'; let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); @@ -21,12 +23,14 @@ const children = $derived(session.childrenOf(channel.id)); const expanded = $derived(session.isExpanded(channel.id)); const here = $derived(session.self?.channelId === channel.id); - const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id); + // Read and written without joining: a click opens its chat + const isText = $derived(session.isText(channel.id)); + const selected = $derived((session.panel.kind === 'channel' && session.panel.id === channel.id) || (isText && session.view === `ch:${channel.id}`)); const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0); const total = $derived(session.subtreeUserCount(channel.id)); // Resting on a channel that has a description for 1 second opens a quick preview next to its row - const hasDescription = $derived(!!(channel.description || channel.descriptionHash)); + const hasDescription = $derived(!!(stripMark(channel.description) || (!channel.description && channel.descriptionHash))); const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : ''); let preview = $state<{ x: number; y: number } | null>(null); let hoverTimer: ReturnType | undefined; @@ -40,6 +44,8 @@ trackPointer(e); clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { + // Not while something is dragged, and not when the row moved away in the meantime + if (drag.item || !rowEl?.matches(':hover')) return; // Long descriptions are only announced by hash; the text is fetched now session.loadDescription(channel.id); // Just below and right of the pointer, kept inside the window @@ -52,6 +58,20 @@ // An image opened from the preview covers it; the preview stays underneath hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250); } + // A row that moves away under the pointer (after a drop) never reports the pointer leaving, + // so an open preview also watches where the pointer is + let rowEl = $state(); + $effect(() => { + if (!preview) return; + let away: ReturnType | undefined; + const inside = () => !!rowEl?.matches(':hover') || !!popEl?.matches(':hover') || !!ui.image; + const check = () => { + if (inside()) { clearTimeout(away); away = undefined; } + else away ??= setTimeout(() => { away = undefined; if (!inside()) preview = null; }, 300); + }; + window.addEventListener('mousemove', check); + return () => { window.removeEventListener('mousemove', check); clearTimeout(away); }; + }); // Back from the image: the preview stays while the pointer is on it, and goes when it is not let popEl = $state(); let hadImage = false; @@ -80,44 +100,99 @@ }; } - // Drop a person to move them here, or a channel to make it a subchannel of this one - let over = $state(false); - const canDrop = $derived.by(() => { + // Drop a person to move them here. A channel dropped on the middle of a row becomes a + // subchannel; dropped on the upper or lower third it goes in front of or behind this one. + type Zone = 'before' | 'after' | 'into'; + let zone = $state(null); + const dragging = $derived(drag.item?.kind === 'channel' && drag.item.id === channel.id); + const open = $derived(expanded && (children.length > 0 || users.length > 0)); + + function intoAllowed(): boolean { const d = drag.item; if (!d) return false; if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id; return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id); - }); + } - function onDrop(e: DragEvent) { - e.preventDefault(); - over = false; - // Check before clearing: canDrop is derived from the dragged item + function zoneAt(e: DragEvent, row: HTMLElement): Zone | null { const d = drag.item; - const ok = canDrop; + if (!d) return null; + const into = intoAllowed(); + if (d.kind === 'user' || channel.id === 0) return into ? 'into' : null; + if (d.id === channel.id || session.isInside(channel.id, d.id)) return null; + const box = row.getBoundingClientRect(); + const y = (e.clientY - box.top) / box.height; + if (y < 0.34) return 'before'; + if (y > 0.66) return 'after'; + if (into) return 'into'; + return y < 0.5 ? 'before' : 'after'; + } + + // Resting on a closed channel opens it, to reach the channels inside + let openTimer: ReturnType | undefined; + function setZone(z: Zone | null) { + if (z === zone) return; + zone = z; + clearTimeout(openTimer); + if (z === 'into' && !expanded && children.length) openTimer = setTimeout(() => { if (zone === 'into' && !expanded) session.toggleExpanded(channel.id); }, 700); + } + + function onDragOver(e: DragEvent, fixed?: Zone) { + const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement); + if (z) e.preventDefault(); + setZone(z); + } + + // Leaving for an element inside the same target is not leaving + function onDragLeave(e: DragEvent) { + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) setZone(null); + } + + function onDrop(e: DragEvent, fixed?: Zone) { + e.preventDefault(); + // Work out the target before clearing: it depends on the dragged item + const d = drag.item; + const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement); + setZone(null); drag.item = null; - if (!d || !ok) return; + const client = session.client; + if (!d || !z || !client) return; if (d.kind === 'user') { if (d.id === session.self?.session) session.joinChannel(channel.id); else session.moveUser(d.id, channel.id); - } else { - session.client?.updateChannel(d.id, { parent: channel.id }); - sounds.play('channelMoved'); + return; } + if (z === 'into') { + client.updateChannel(d.id, { parent: channel.id }); + } else { + const moved = session.channel(d.id); + if (!moved || channel.parent == null) return; + const rest = session.childrenOf(channel.parent).filter(c => c.id !== d.id); + const plan = planMove(rest, moved, rest.findIndex(c => c.id === channel.id) + (z === 'after' ? 1 : 0)); + const own = plan.find(u => u.id === d.id); + const reparent = moved.parent !== channel.parent; + if (!reparent && !plan.length) return; + if (reparent || own) client.updateChannel(d.id, { ...(reparent ? { parent: channel.parent } : {}), ...(own ? { position: own.position } : {}) }); + for (const u of plan) if (u.id !== d.id) client.updateChannel(u.id, { position: u.position }); + } + sounds.play('channelMoved'); } - const channelClicks = clickOrDouble( + const openText = () => { session.selectView(`ch:${channel.id}`); onnavigate(); }; + const channelClicks = $derived(isText ? { onclick: openText } : clickOrDouble( () => { session.showChannel(channel.id); ui.panelOpen = true; }, () => { session.joinChannel(channel.id); onnavigate(); } - ); + )); -
  • -
    +
  • +
    menu.show(e, channelMenu(channel))} role="presentation" - ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} - ondragleave={() => (over = false)} - ondrop={onDrop} + ondragover={onDragOver} + ondragleave={onDragLeave} + ondrop={onDrop} bind:this={rowEl} onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}> {#if children.length || users.length} + {:else if session.self?.channelId === channel.id} You are in this channel {:else}