- Signals travel as Mumble plugin data (compressed, chunked under Murmur's 1000 byte limit); the picture goes directly to each viewer over WebRTC - Toolbar button with a source picker in the desktop app, an indicator next to people who share, click to watch, a view above the chat with full screen - Late joiners learn about running streams; moving, stopping or disconnecting ends them for viewers - A note about IP addresses before the first use, optional STUN server setting - Unit tests for the signal codec and an E2E with two app instances Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
834 lines
33 KiB
TypeScript
834 lines
33 KiB
TypeScript
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 } from './share.svelte.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';
|
|
import { uploadFile, makeThumbnail, makeInlineImage, fetchUploadInfo, checkFile, type UploadInfo } from './upload.ts';
|
|
import { voice } from './audio/voice.svelte.ts';
|
|
import { sounds } from './audio/sounds.svelte.ts';
|
|
|
|
const codec = createCodec();
|
|
|
|
// A chat view is a channel ("ch:<id>") or a direct conversation with a user ("dm:<session>").
|
|
export type ViewKey = `ch:${number}` | `dm:${number}`;
|
|
|
|
export interface ChatEntry {
|
|
id: number;
|
|
kind: 'text' | 'system';
|
|
author: string;
|
|
authorSession: number | null;
|
|
self: boolean;
|
|
html: string;
|
|
embeds: Embed[];
|
|
time: number;
|
|
}
|
|
|
|
export interface UploadJob {
|
|
id: number;
|
|
key: string;
|
|
name: string;
|
|
progress: number;
|
|
error: string;
|
|
}
|
|
|
|
export interface CertPrompt {
|
|
server: SavedServer;
|
|
hostPort: string;
|
|
fingerprint: string;
|
|
previous: string;
|
|
}
|
|
|
|
// Right-hand panel: member list, a channel (description preview or its side chat), or a user profile
|
|
export type Panel =
|
|
| { kind: 'members' }
|
|
| { kind: 'channel'; id: number; tab: 'info' | 'chat' }
|
|
| { kind: 'user'; session: number };
|
|
|
|
let nextId = 1;
|
|
|
|
export class Session {
|
|
status = $state<'idle' | 'connecting' | 'connected'>('idle');
|
|
error = $state('');
|
|
server = $state<SavedServer | null>(null);
|
|
// Bumped on every client state change; views read it to re-derive from the client's maps
|
|
tick = $state(0);
|
|
view = $state<ViewKey>('ch:0');
|
|
panel = $state<Panel>({ kind: 'members' });
|
|
logs = $state<Record<string, ChatEntry[]>>({});
|
|
unread = $state<Record<string, number>>({});
|
|
uploads = $state<UploadJob[]>([]);
|
|
certPrompt = $state<CertPrompt | null>(null);
|
|
rtt = $state(0);
|
|
// Who in our channel is sharing their screen, by session (mumh5 clients only)
|
|
sharing = $state<Record<number, boolean>>({});
|
|
// Why the last connection ended, shown with a Reconnect button (null after a normal disconnect)
|
|
disconnectInfo = $state<{ kind: 'kicked' | 'banned' | 'lost' | 'rejected'; by: string; reason: string } | null>(null);
|
|
private removedBy: { kind: 'kicked' | 'banned'; by: string; reason: string } | null = null;
|
|
// Last known channel per user, to tell joins from leaves of our channel for sounds
|
|
private lastChannel = new Map<number, number>();
|
|
// TLS details of the current server, for the certificate viewer and the trusted badge
|
|
serverCert = $state<ServerCertInfo | null>(null);
|
|
uploadInfo = $state<UploadInfo | null>(null);
|
|
// Channel tree expansion the user chose; channels not in here follow the default
|
|
expanded = $state<Record<number, boolean>>({});
|
|
|
|
client: MumbleClient | null = null;
|
|
// Incremented per connect so a slow, superseded attempt can tell it is stale
|
|
private attempt = 0;
|
|
private uploadInfoFor = '';
|
|
// Blob hashes already requested, so a preview does not ask the server repeatedly
|
|
private requested = new Set<string>();
|
|
// Last message with an inline image, resent as a plain link if the server refuses it
|
|
private lastImageSend: { key: string; fallback: string; at: number } | null = null;
|
|
// Channel we asked to move to; the chat view switches when the server confirms it
|
|
private pendingJoin: number | null = null;
|
|
|
|
// ─── Derived helpers ────────────────────────────────────────────────────────
|
|
// The client mutates its objects in place. These return shallow copies and read `tick`,
|
|
// so every state change hands Svelte new objects and dependent views update.
|
|
|
|
get self(): User | null {
|
|
void this.tick;
|
|
const u = this.client?.self;
|
|
return u ? { ...u } : null;
|
|
}
|
|
|
|
channel(id: number): Channel | undefined {
|
|
void this.tick;
|
|
const c = this.client?.channels.get(id);
|
|
return c && { ...c };
|
|
}
|
|
|
|
user(session: number): User | undefined {
|
|
void this.tick;
|
|
const u = this.client?.users.get(session);
|
|
return u && { ...u };
|
|
}
|
|
|
|
childrenOf(parent: number | null): Channel[] {
|
|
void this.tick;
|
|
if (!this.client) return [];
|
|
return [...this.client.channels.values()]
|
|
.filter(c => (parent === null ? c.id === 0 : c.parent === parent && c.id !== 0))
|
|
.sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))
|
|
.map(c => ({ ...c }));
|
|
}
|
|
|
|
usersIn(channelId: number): User[] {
|
|
void this.tick;
|
|
if (!this.client) return [];
|
|
return [...this.client.users.values()]
|
|
.filter(u => u.channelId === channelId)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map(u => ({ ...u }));
|
|
}
|
|
|
|
allUsers(): User[] {
|
|
void this.tick;
|
|
return this.client ? [...this.client.users.values()].sort((a, b) => a.name.localeCompare(b.name)).map(u => ({ ...u })) : [];
|
|
}
|
|
|
|
// Users in a channel and all of its subchannels
|
|
subtreeUserCount(id: number): number {
|
|
void this.tick;
|
|
const client = this.client;
|
|
if (!client) return 0;
|
|
const ids = new Set([id]);
|
|
let grew = true;
|
|
while (grew) {
|
|
grew = false;
|
|
for (const c of client.channels.values()) {
|
|
if (c.parent != null && ids.has(c.parent) && !ids.has(c.id)) { ids.add(c.id); grew = true; }
|
|
}
|
|
}
|
|
let n = 0;
|
|
for (const u of client.users.values()) if (ids.has(u.channelId)) n++;
|
|
return n;
|
|
}
|
|
|
|
// Collapsed by default; channels with people in them open up, like the desktop client
|
|
isExpanded(id: number): boolean {
|
|
if (id === 0) return true;
|
|
return this.expanded[id] ?? this.subtreeUserCount(id) > 0;
|
|
}
|
|
|
|
toggleExpanded(id: number): void {
|
|
this.expanded[id] = !this.isExpanded(id);
|
|
}
|
|
|
|
// Collapsing goes back to the default: channels with people in them stay open
|
|
setAllExpanded(open: boolean): void {
|
|
const all: Record<number, boolean> = {};
|
|
if (open) for (const id of this.client?.channels.keys() ?? []) all[id] = true;
|
|
this.expanded = all;
|
|
}
|
|
|
|
// What the server told us about itself, for the server information dialog
|
|
get serverInfo() {
|
|
void this.tick;
|
|
const c = this.client;
|
|
if (!c) return null;
|
|
return { version: c.serverVersion, maxBandwidth: c.maxBandwidth, config: { ...c.config }, users: c.users.size, channels: c.channels.size };
|
|
}
|
|
|
|
// How voice travels right now
|
|
get voiceTransport(): { udp: boolean; rtt: number } {
|
|
void this.tick;
|
|
const c = this.client;
|
|
// The setting (reactive) rather than the client's copy, so toggling it updates the view
|
|
return { udp: !!c && c.udpOk && !voice.settings.forceTcp, rtt: c?.udpRtt ?? 0 };
|
|
}
|
|
|
|
// true / false when known, null when the server has not told us yet
|
|
can(channelId: number, bit: number): boolean | null {
|
|
void this.tick;
|
|
return this.client?.can(channelId, bit) ?? null;
|
|
}
|
|
|
|
// Whether a channel lies somewhere below another (a channel cannot move into its own subtree)
|
|
isInside(channelId: number, ancestor: number): boolean {
|
|
let c = this.client?.channels.get(channelId);
|
|
while (c && c.parent != null) {
|
|
if (c.parent === ancestor) return true;
|
|
c = this.client?.channels.get(c.parent);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
viewTitle(key: string = this.view): string {
|
|
const [kind, id] = key.split(':');
|
|
if (kind === 'ch') return this.channel(Number(id))?.name || 'Root';
|
|
return this.user(Number(id))?.name ?? 'Offline user';
|
|
}
|
|
|
|
// Keys whose chat is on screen right now (main view and an open side chat)
|
|
private visibleKeys(): string[] {
|
|
const keys: string[] = [this.view];
|
|
if (this.panel.kind === 'channel' && this.panel.tab === 'chat') keys.push(`ch:${this.panel.id}`);
|
|
return keys;
|
|
}
|
|
|
|
// ─── Connection ─────────────────────────────────────────────────────────────
|
|
|
|
async connect(server: SavedServer): Promise<void> {
|
|
this.disconnect();
|
|
const attempt = ++this.attempt;
|
|
this.server = server;
|
|
this.error = '';
|
|
this.logs = {};
|
|
this.unread = {};
|
|
this.expanded = {};
|
|
this.panel = { kind: 'members' };
|
|
this.requested.clear();
|
|
this.serverCert = null;
|
|
this.disconnectInfo = null;
|
|
this.removedBy = null;
|
|
this.status = 'connecting';
|
|
|
|
if (!native) {
|
|
this.fail('Connecting needs the desktop app or the web build served by a mumh5 proxy.');
|
|
return;
|
|
}
|
|
|
|
let transport;
|
|
let info;
|
|
try {
|
|
transport = (await openTransport(server.host, server.port, server.identityId))!;
|
|
// Superseded while the identity was being prepared
|
|
if (attempt !== this.attempt) return transport.close();
|
|
info = await transport.secure;
|
|
} catch (e) {
|
|
if (attempt === this.attempt) this.fail((e as Error).message);
|
|
return;
|
|
}
|
|
if (attempt !== this.attempt) return transport.close();
|
|
this.serverCert = info;
|
|
|
|
// Trust on first use: pin the certificate, and stop before sending credentials if it changed
|
|
const hostPort = `${server.host.toLowerCase()}:${server.port}`;
|
|
const pinned = store.pins[hostPort];
|
|
if (!pinned) {
|
|
store.pin(hostPort, info.fingerprint);
|
|
} else if (pinned !== info.fingerprint && !info.authorized) {
|
|
transport.close();
|
|
this.certPrompt = { server, hostPort, fingerprint: info.fingerprint, previous: pinned };
|
|
this.fail('The server certificate changed.');
|
|
return;
|
|
}
|
|
|
|
const client = new MumbleClient(codec);
|
|
client.forceTcp = voice.settings.forceTcp;
|
|
this.client = client;
|
|
this.wire(client);
|
|
// TLS is up but the server may still never finish the login
|
|
setTimeout(() => { if (this.client === client && !client.synced) client.disconnect('Login timed out'); }, 20000);
|
|
const platform = await native.platformInfo();
|
|
if (attempt !== this.attempt) return transport.close();
|
|
client.connect(transport, {
|
|
username: server.username,
|
|
password: server.password,
|
|
os: platform.os,
|
|
osVersion: platform.osVersion
|
|
});
|
|
}
|
|
|
|
reconnect(): void {
|
|
if (this.server) this.connect($state.snapshot(this.server) as SavedServer);
|
|
}
|
|
|
|
acceptCertificate(): void {
|
|
const p = this.certPrompt;
|
|
if (!p) return;
|
|
store.pin(p.hostPort, p.fingerprint);
|
|
this.certPrompt = null;
|
|
this.connect(p.server);
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.attempt++;
|
|
const client = this.client;
|
|
this.client = null;
|
|
client?.disconnect();
|
|
share.closed(this);
|
|
this.sharing = {};
|
|
sessions.onClosed(this);
|
|
this.status = 'idle';
|
|
this.tick++;
|
|
}
|
|
|
|
private fail(msg: string): void {
|
|
this.status = 'idle';
|
|
this.error = msg;
|
|
}
|
|
|
|
private wire(client: MumbleClient): void {
|
|
const live = () => this.client === client;
|
|
const bump = () => { if (live()) this.tick++; };
|
|
|
|
client.on('synced', () => {
|
|
if (!live()) return;
|
|
this.status = 'connected';
|
|
this.view = `ch:${client.self?.channelId ?? 0}`;
|
|
sessions.onSynced(this);
|
|
sounds.play('connected');
|
|
for (const u of client.users.values()) this.lastChannel.set(u.session, u.channelId);
|
|
// What we may do per channel, for menus (not rate limited by the server)
|
|
for (const id of client.channels.keys()) client.requestPermissions(id);
|
|
if (client.welcomeText) this.system(this.view, client.welcomeText, true);
|
|
share.entered(this);
|
|
bump();
|
|
});
|
|
client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); });
|
|
client.on('channel', ch => {
|
|
if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id);
|
|
bump();
|
|
});
|
|
client.on('permissions', () => {
|
|
if (!live()) return;
|
|
// A flush means permissions changed somewhere (e.g. an ACL edit); ask again
|
|
if (!client.permissions.size) for (const id of client.channels.keys()) client.requestPermissions(id);
|
|
bump();
|
|
});
|
|
client.on('channelRemove', id => {
|
|
if (!live()) return;
|
|
if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' };
|
|
bump();
|
|
});
|
|
client.on('ping', rtt => { if (live()) this.rtt = rtt; });
|
|
client.on('udp', bump);
|
|
|
|
client.on('user', (user, changed, actor, isNew) => {
|
|
if (!live()) return;
|
|
const self = client.self;
|
|
const before = this.lastChannel.get(user.session);
|
|
this.lastChannel.set(user.session, user.channelId);
|
|
if (client.synced && self && (isNew || changed.includes('channelId'))) {
|
|
if (user.session === self.session) share.entered(this);
|
|
else share.userMoved(this, user);
|
|
}
|
|
if (client.synced && self && this.isVoice()) this.userSound(user.session === self.session, user, changed, actor, isNew, before, self.channelId);
|
|
if (client.synced && self) {
|
|
if (isNew) {
|
|
if (user.channelId === self.channelId) this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} connected`);
|
|
} else if (changed.includes('userId') && user.userId != null) {
|
|
const who = user.session === self.session ? 'You are' : `${escapeHtml(user.name)} is`;
|
|
this.system(this.view, `${who} now registered on this server`);
|
|
} else if (changed.includes('channelId')) {
|
|
if (user.session === self.session) {
|
|
// Follow our own channel moves in the chat view (also from a DM when we asked to join)
|
|
if (this.view.startsWith('ch:') || this.pendingJoin === user.channelId) this.selectView(`ch:${user.channelId}`);
|
|
this.pendingJoin = null;
|
|
this.system(`ch:${user.channelId}`, `You joined ${escapeHtml(this.channel(user.channelId)?.name || 'Root')}`);
|
|
} else if (user.channelId === self.channelId) {
|
|
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} joined the channel`);
|
|
}
|
|
}
|
|
}
|
|
bump();
|
|
});
|
|
|
|
client.on('userRemove', (user, actor, reason, ban) => {
|
|
if (!live()) return;
|
|
// We were kicked or banned; the server closes the connection right after
|
|
if (user.session === client.session) {
|
|
const by = actor != null ? client.users.get(actor)?.name ?? '' : '';
|
|
this.removedBy = { kind: ban ? 'banned' : 'kicked', by, reason };
|
|
}
|
|
const self = client.self;
|
|
share.userMoved(this, user, true);
|
|
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');
|
|
}
|
|
this.lastChannel.delete(user.session);
|
|
if (self && user.channelId === self.channelId) {
|
|
const by = actor != null && actor !== user.session ? client.users.get(actor)?.name : null;
|
|
const what = by ? `${ban ? 'was banned' : 'was kicked'} by ${escapeHtml(by)}` : 'disconnected';
|
|
this.system(`ch:${self.channelId}`, `${escapeHtml(user.name)} ${what}${reason ? `: ${escapeHtml(reason)}` : ''}`);
|
|
}
|
|
if (this.panel.kind === 'user' && this.panel.session === user.session) this.panel = { kind: 'members' };
|
|
bump();
|
|
});
|
|
|
|
client.on('text', msg => {
|
|
if (!live()) return;
|
|
this.receive(client, msg);
|
|
if (msg.actor != null && msg.actor !== client.session) sounds.play(msg.channels.length || msg.trees.length ? 'message' : 'privateMessage');
|
|
});
|
|
|
|
client.on('permissionDenied', d => {
|
|
if (!live()) return;
|
|
// An image preview can exceed what the server accepts; the link alone still gets through
|
|
const img = this.lastImageSend;
|
|
if (d.type === 4 && img && Date.now() - img.at < 5000) {
|
|
this.lastImageSend = null;
|
|
this.system(img.key, 'The server refused the image preview, sent the link only');
|
|
client.sendText(this.target(img.key), img.fallback);
|
|
return;
|
|
}
|
|
if (d.channelId != null && d.channelId === this.pendingJoin) this.pendingJoin = null;
|
|
sounds.play('denied');
|
|
const ch = d.channelId != null ? client.channels.get(d.channelId)?.name : undefined;
|
|
this.system(this.view, escapeHtml(describeDenial(d, ch)));
|
|
});
|
|
|
|
client.on('reject', (_type, reason) => {
|
|
if (!live()) return;
|
|
this.removedBy = null;
|
|
this.disconnectInfo = { kind: 'rejected', by: '', reason };
|
|
});
|
|
|
|
client.on('close', reason => {
|
|
if (this.client !== client) return;
|
|
if (this.removedBy) this.disconnectInfo = this.removedBy;
|
|
else if (!this.disconnectInfo && reason !== 'Disconnected') this.disconnectInfo = { kind: 'lost', by: '', reason };
|
|
const kind = this.disconnectInfo?.kind;
|
|
sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected');
|
|
this.client = null;
|
|
sessions.onClosed(this);
|
|
this.status = 'idle';
|
|
this.error = reason === 'Disconnected' ? '' : reason;
|
|
this.tick++;
|
|
});
|
|
}
|
|
|
|
// Presence sounds only come from the server where voice runs; others would be noise
|
|
private isVoice(): boolean {
|
|
return sessions.voiceId === this.server?.id;
|
|
}
|
|
|
|
private userSound(isSelf: boolean, user: User, changed: string[], actor: number | null, isNew: boolean, before: number | undefined, mine: number): void {
|
|
if (isSelf) {
|
|
const byOther = actor != null && actor !== user.session;
|
|
if (changed.includes('channelId')) sounds.play(byOther ? 'moved' : 'switchedChannel');
|
|
if (!byOther && changed.includes('selfDeaf')) sounds.play(user.selfDeaf ? 'deafen' : 'undeafen');
|
|
else if (!byOther && changed.includes('selfMute')) sounds.play(user.selfMute ? 'mute' : 'unmute');
|
|
return;
|
|
}
|
|
const self = actor == null || actor === user.session;
|
|
if (isNew) {
|
|
if (user.channelId === mine) sounds.play('userConnected');
|
|
} else if (changed.includes('channelId')) {
|
|
if (user.channelId === mine) sounds.play(self ? 'userJoined' : 'userMovedIn');
|
|
else if (before === mine) sounds.play(self ? 'userLeft' : 'userMovedOut');
|
|
}
|
|
if (changed.includes('recording') && user.channelId === mine) sounds.play(user.recording ? 'recordingStarted' : 'recordingStopped');
|
|
}
|
|
|
|
// ─── Panels, descriptions, comments ─────────────────────────────────────────
|
|
|
|
showChannel(id: number, tab: 'info' | 'chat' = 'info'): void {
|
|
this.panel = { kind: 'channel', id, tab };
|
|
if (tab === 'chat') delete this.unread[`ch:${id}`];
|
|
this.loadDescription(id);
|
|
}
|
|
|
|
showUser(session: number): void {
|
|
this.panel = { kind: 'user', session };
|
|
this.loadComment(session);
|
|
}
|
|
|
|
closePanel(): void {
|
|
this.panel = { kind: 'members' };
|
|
}
|
|
|
|
// Long descriptions and comments are only announced by hash; fetch the text on demand
|
|
loadDescription(id: number): void {
|
|
const c = this.client?.channels.get(id);
|
|
if (!c?.descriptionHash || c.description) return;
|
|
const tag = `d:${id}:${[...c.descriptionHash].join(',')}`;
|
|
if (this.requested.has(tag)) return;
|
|
this.requested.add(tag);
|
|
this.client!.requestBlob({ descriptions: [id] });
|
|
}
|
|
|
|
loadComment(session: number): void {
|
|
const u = this.client?.users.get(session);
|
|
if (!u?.commentHash || u.comment) return;
|
|
const tag = `c:${session}:${[...u.commentHash].join(',')}`;
|
|
if (this.requested.has(tag)) return;
|
|
this.requested.add(tag);
|
|
this.client!.requestBlob({ comments: [session] });
|
|
}
|
|
|
|
register(session: number): void {
|
|
this.client?.register(session);
|
|
}
|
|
|
|
// Mumble has no rename while connected: reconnect under the new name (saved for this server)
|
|
changeName(name: string): void {
|
|
const server = this.server;
|
|
if (!server || !name.trim()) return;
|
|
const updated = { ...$state.snapshot(server), username: name.trim() };
|
|
store.upsertServer(updated);
|
|
this.connect(updated);
|
|
}
|
|
|
|
// html comes from the rich editor, already sanitized XHTML
|
|
setComment(html: string): void {
|
|
this.client?.setComment(html);
|
|
}
|
|
|
|
// Largest description or image message the server accepts (0: no limit)
|
|
get htmlLimit(): number {
|
|
void this.tick;
|
|
return this.client?.config.imageMessageLength ?? 131072;
|
|
}
|
|
|
|
// ─── Chat ───────────────────────────────────────────────────────────────────
|
|
|
|
private push(key: string, entry: Omit<ChatEntry, 'id' | 'time'>): 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;
|
|
}
|
|
|
|
private system(key: string, html: string, trusted = false): void {
|
|
const rendered = trusted ? renderIncoming(html, store.trustedHosts) : { html, embeds: [] };
|
|
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
|
|
}
|
|
|
|
private receive(client: MumbleClient, msg: TextMessage): void {
|
|
const self = client.self;
|
|
const sender = msg.actor != null ? client.users.get(msg.actor) : undefined;
|
|
let key: string;
|
|
if (msg.actor == null) key = this.view;
|
|
else if (msg.channels.length || msg.trees.length) key = `ch:${msg.channels[0] ?? self?.channelId ?? 0}`;
|
|
else key = `dm:${msg.actor}`;
|
|
|
|
const rendered = renderIncoming(msg.html, store.trustedHosts);
|
|
if (msg.actor == null) {
|
|
this.push(key, { kind: 'system', author: '', authorSession: null, self: false, ...rendered });
|
|
} else {
|
|
this.push(key, { kind: 'text', author: sender ? store.displayName(sender) : `#${msg.actor}`, authorSession: msg.actor, self: false, ...rendered });
|
|
}
|
|
}
|
|
|
|
// Opening a server marks what is on screen as read
|
|
markVisibleRead(): void {
|
|
for (const k of this.visibleKeys()) delete this.unread[k];
|
|
}
|
|
|
|
selectView(key: ViewKey): void {
|
|
this.view = key;
|
|
delete this.unread[key];
|
|
}
|
|
|
|
// The view follows once the server confirms the move (see the 'user' handler). Murmur
|
|
// silently drops own state changes sent too quickly (leaky bucket, about one per second),
|
|
// so an unconfirmed join is sent once more before giving up.
|
|
joinChannel(id: number): void {
|
|
const client = this.client;
|
|
if (!client) return;
|
|
// Joining a channel is where you want to talk: voice moves to this server
|
|
sessions.setVoice(this);
|
|
if (this.self?.channelId === id) {
|
|
this.selectView(`ch:${id}`);
|
|
return;
|
|
}
|
|
this.pendingJoin = id;
|
|
client.joinChannel(id);
|
|
const moved = () => this.client !== client || this.pendingJoin !== id || client.self?.channelId === id;
|
|
setTimeout(() => {
|
|
if (moved()) return;
|
|
client.joinChannel(id);
|
|
setTimeout(() => {
|
|
if (moved()) return;
|
|
this.pendingJoin = null;
|
|
this.system(this.view, `The server did not move you to ${escapeHtml(client.channels.get(id)?.name || 'Root')}. Try again in a moment.`);
|
|
}, 1500);
|
|
}, 1200);
|
|
}
|
|
|
|
private target(key: string): { channels?: number[]; users?: number[] } {
|
|
const [kind, id] = key.split(':');
|
|
return kind === 'ch' ? { channels: [Number(id)] } : { users: [Number(id)] };
|
|
}
|
|
|
|
// Send HTML to a view and echo it locally (the server does not echo our messages).
|
|
sendHtml(html: string, key: string = this.view): boolean {
|
|
const client = this.client;
|
|
if (!client || this.status !== 'connected') return false;
|
|
const limit = /<img/i.test(html) ? client.config.imageMessageLength : client.config.messageLength;
|
|
if (limit && html.length > limit) {
|
|
this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`);
|
|
return false;
|
|
}
|
|
client.sendText(this.target(key), html);
|
|
sounds.play('messageSent');
|
|
const rendered = renderIncoming(html, store.trustedHosts);
|
|
this.push(key, { kind: 'text', author: client.self?.name ?? '', authorSession: client.session, self: true, ...rendered });
|
|
return true;
|
|
}
|
|
|
|
sendText(text: string, key: string = this.view): boolean {
|
|
const trimmed = text.trim();
|
|
return trimmed ? this.sendHtml(textToHtml(trimmed), key) : false;
|
|
}
|
|
|
|
async uploadFiles(files: File[], key: string = this.view): Promise<void> {
|
|
const { uploadHost, uploadKey, uploadExpiry, sendThumbnails } = store.settings;
|
|
if (!uploadHost || !uploadKey) return this.sendInlineImages(files, key);
|
|
let info: UploadInfo;
|
|
try {
|
|
info = await this.loadUploadInfo();
|
|
} catch (e) {
|
|
this.system(key, `File sharing unavailable: ${escapeHtml((e as Error).message)}`);
|
|
return;
|
|
}
|
|
for (const f of files) {
|
|
const problem = checkFile(info, f);
|
|
if (problem) this.system(key, escapeHtml(problem));
|
|
}
|
|
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);
|
|
const live = () => this.uploads.find(j => j.id === job.id)!;
|
|
try {
|
|
const res = await uploadFile(uploadHost, uploadKey, file, uploadExpiry, p => { live().progress = p; });
|
|
// Well-formed XHTML (<br/>, <img/>): Murmur rejects long messages that do not parse as XML
|
|
const link = `<a href="${escapeHtml(res.url)}">${escapeHtml(res.name)}</a> (${formatBytes(res.size)})`;
|
|
let html = link;
|
|
const cfg = this.client?.config;
|
|
if (sendThumbnails && cfg?.allowHtml !== false) {
|
|
const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64;
|
|
const thumb = await makeThumbnail(file, budget);
|
|
if (thumb) html += `<br/><img src="${thumb}"/>`;
|
|
}
|
|
if (html !== link) this.lastImageSend = { key, fallback: link, at: Date.now() };
|
|
this.sendHtml(html, key);
|
|
this.uploads = this.uploads.filter(j => j.id !== job.id);
|
|
sounds.play('uploadDone');
|
|
} catch (e) {
|
|
live().error = (e as Error).message;
|
|
sounds.play('uploadFailed');
|
|
setTimeout(() => { this.uploads = this.uploads.filter(j => j.id !== job.id); }, 6000);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Without file hosting: images go inline like a pasted picture in the desktop client,
|
|
// scaled to the server's image limit. Other files need an upload host.
|
|
private async sendInlineImages(files: File[], key: string): Promise<void> {
|
|
const cfg = this.client?.config;
|
|
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) {
|
|
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 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)})`);
|
|
continue;
|
|
}
|
|
this.sendHtml(`<img src="${uri}"/>`, key);
|
|
}
|
|
}
|
|
|
|
// Cached per host+key; refreshed when settings change
|
|
async loadUploadInfo(): Promise<UploadInfo> {
|
|
const { uploadHost, uploadKey } = store.settings;
|
|
const id = `${uploadHost}|${uploadKey}`;
|
|
if (this.uploadInfo && this.uploadInfoFor === id) return this.uploadInfo;
|
|
const info = await fetchUploadInfo(uploadHost, uploadKey);
|
|
this.uploadInfo = info;
|
|
this.uploadInfoFor = id;
|
|
return info;
|
|
}
|
|
|
|
// ─── Voice state and moderation ─────────────────────────────────────────────
|
|
|
|
setSharing(session: number, on: boolean): void {
|
|
if (on) this.sharing[session] = true;
|
|
else delete this.sharing[session];
|
|
}
|
|
clearSharing(): void { this.sharing = {}; }
|
|
|
|
// The choice is remembered, so it also works while disconnected and carries to the next connect
|
|
setSelfMute(v: boolean) {
|
|
sessions.mute = v;
|
|
if (!v) sessions.deaf = false;
|
|
this.client?.setSelfMute(v);
|
|
}
|
|
setSelfDeaf(v: boolean) {
|
|
sessions.deaf = v;
|
|
if (v) sessions.mute = true;
|
|
this.client?.setSelfDeaf(v);
|
|
}
|
|
|
|
kick(session: number, reason: string, ban = false) { this.client?.kick(session, reason, ban); }
|
|
setServerMute(session: number, v: boolean) { this.client?.setUserState(session, { mute: v }); }
|
|
setServerDeaf(session: number, v: boolean) { this.client?.setUserState(session, { deaf: v }); }
|
|
setPrioritySpeaker(session: number, v: boolean) { this.client?.setUserState(session, { priority_speaker: v }); }
|
|
moveUser(session: number, channelId: number) { this.client?.setUserState(session, { channel_id: channelId }); }
|
|
}
|
|
|
|
// ─── Several servers at once ───────────────────────────────────────────────────
|
|
// Every server has its own Session. Text works on all of them; voice runs on one (like
|
|
// Discord): the server where you last joined a channel. On the others you are shown as
|
|
// deafened, so nobody expects you to hear them, and your state is restored when voice returns.
|
|
|
|
class Sessions {
|
|
list = $state<Session[]>([]);
|
|
activeId = $state<string | null>(null);
|
|
voiceId = $state<string | null>(null);
|
|
private idle = new Session();
|
|
// Self mute/deaf as the user last chose it, like the desktop client: shown and toggled
|
|
// while disconnected and applied to servers when they connect
|
|
mute = $state(false);
|
|
deaf = $state(false);
|
|
// Self mute/deaf before we deafened a background server, to restore later
|
|
private saved = new Map<Session, { mute: boolean; deaf: boolean }>();
|
|
|
|
get active(): Session {
|
|
return this.list.find(s => s.server?.id === this.activeId) ?? this.idle;
|
|
}
|
|
|
|
byServer(id: string): Session | undefined {
|
|
return this.list.find(s => s.server?.id === id);
|
|
}
|
|
|
|
// Show a server, connecting if needed
|
|
open(server: SavedServer): void {
|
|
let s = this.byServer(server.id);
|
|
this.activeId = server.id;
|
|
s?.markVisibleRead();
|
|
if (!s) {
|
|
s = new Session();
|
|
this.list.push(s);
|
|
s = this.list[this.list.length - 1];
|
|
}
|
|
if (s.status === 'idle') s.connect(server);
|
|
}
|
|
|
|
select(id: string): void {
|
|
this.activeId = id;
|
|
this.active.markVisibleRead();
|
|
}
|
|
|
|
close(id: string): void {
|
|
const s = this.byServer(id);
|
|
if (!s) return;
|
|
s.disconnect();
|
|
this.list = this.list.filter(x => x !== s);
|
|
if (this.activeId === id) this.activeId = this.list.find(x => x.status !== 'idle')?.server?.id ?? null;
|
|
}
|
|
|
|
get voiceSession(): Session | undefined {
|
|
return this.list.find(s => s.server?.id === this.voiceId);
|
|
}
|
|
|
|
onSynced(s: Session): void {
|
|
const want = { mute: this.mute, deaf: this.deaf };
|
|
if (!this.voiceId || !this.voiceSession?.client) {
|
|
this.setVoice(s, true);
|
|
if (want.mute) s.client?.send('UserState', { self_mute: true, self_deaf: want.deaf });
|
|
} else {
|
|
this.toBackground(s);
|
|
// A fresh login is unmuted; restore the user's choice when voice comes here
|
|
if (this.saved.has(s)) this.saved.set(s, want);
|
|
}
|
|
}
|
|
|
|
onClosed(s: Session): void {
|
|
this.saved.delete(s);
|
|
if (this.voiceId !== s.server?.id) return;
|
|
voice.detach();
|
|
// Hand voice to another connected server, if any
|
|
const next = this.list.find(x => x !== s && x.client && x.status === 'connected');
|
|
this.voiceId = null;
|
|
if (next) this.setVoice(next);
|
|
}
|
|
|
|
setVoice(s: Session, first = false): void {
|
|
const id = s.server?.id ?? null;
|
|
if (!id || (this.voiceId === id && !first)) return;
|
|
const prev = this.voiceSession;
|
|
if (prev && prev !== s) {
|
|
voice.detach();
|
|
this.toBackground(prev);
|
|
}
|
|
this.voiceId = id;
|
|
this.fromBackground(s);
|
|
if (s.client) voice.attach(s.client);
|
|
}
|
|
|
|
private toBackground(s: Session): void {
|
|
const self = s.client?.self;
|
|
if (!s.client || !self || this.saved.has(s)) return;
|
|
this.saved.set(s, { mute: self.selfMute, deaf: self.selfDeaf });
|
|
if (!self.selfDeaf) s.client.setSelfDeaf(true);
|
|
}
|
|
|
|
private fromBackground(s: Session): void {
|
|
const prev = this.saved.get(s);
|
|
this.saved.delete(s);
|
|
if (!prev || !s.client || prev.deaf) return;
|
|
s.client.send('UserState', { self_deaf: false, self_mute: prev.mute });
|
|
}
|
|
}
|
|
|
|
export const sessions = new Sessions();
|
|
|
|
// The session of the server on screen. Forwards to the active Session so components keep
|
|
// using `session.x`; methods are bound to the real instance so async work stays with its server.
|
|
export const session = new Proxy({} as Session, {
|
|
get(_t, prop) {
|
|
const a = sessions.active;
|
|
const v = Reflect.get(a, prop, a);
|
|
return typeof v === 'function' ? v.bind(a) : v;
|
|
},
|
|
set(_t, prop, value) {
|
|
return Reflect.set(sessions.active, prop, value, sessions.active);
|
|
}
|
|
});
|