Add the Rooms interface: one screen at a time, made for phones

A second shell on the same state, chosen in Settings, Appearance.
Rooms as a list with the people inside, a voice dock, a voice screen,
people and a You screen. Dialogs and menus are shared with the standard
interface. Moving channels and people by dragging is not in it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 20:30:54 +02:00
co-authored by Claude Opus 5.5
parent 30a75a0629
commit 5c7942d600
17 changed files with 1079 additions and 8 deletions
+5
View File
@@ -19,6 +19,7 @@
import ShareDialog from './ui/ShareDialog.svelte';
import { share } from './lib/share.svelte.ts';
import ResizeHandle from './ui/ResizeHandle.svelte';
import Shell from './ui2/Shell.svelte';
import { identities } from './lib/identities.svelte.ts';
import { session } from './lib/session.svelte.ts';
import { ui } from './lib/ui.svelte.ts';
@@ -92,6 +93,9 @@
onmenu={() => (navOpen = true)} onpanel={togglePanel} />
{/snippet}
{#if store.settings.interface === 'rooms'}
<Shell onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)} />
{:else}
<div class="app {layout} {arrangement}">
{#if arrangement === 'classic'}
{@render rail()}
@@ -127,6 +131,7 @@
{/if}
{/if}
</div>
{/if}
{#if editing}<ConnectDialog server={editing.server} onclose={() => (editing = null)} />{/if}
{#if ui.addServer}<ConnectDialog server={null} prefill={ui.addServer} onclose={() => (ui.addServer = null)} />{/if}
+5
View File
@@ -35,5 +35,10 @@ export const ICON_PATHS: Record<string, string> = {
link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1-1',
image: 'M3 3h18v18H3zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
more: 'M4 11h2v2H4zM11 11h2v2h-2zM18 11h2v2h-2z',
back: 'M19 12H5M11 6l-6 6 6 6',
search: 'M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zM16 16l5 5',
user: 'M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1',
rooms: 'M3 3h8v8H3zM13 3h8v8h-8zM3 13h8v8H3zM13 13h8v8h-8z',
eraser: 'M20 20H7L3 16l10-10 7 7-6.5 7M6 11l7 7'
};
+3
View File
@@ -24,6 +24,8 @@ export interface Settings {
theme: ThemeId;
publicListUrl: string; // where the public server list comes from
arrangement: Arrangement;
// standard: the channel tree in one of the layouts above. rooms: one screen at a time, made for phones.
interface: 'standard' | 'rooms';
shareQuality: 'low' | 'medium' | 'high'; // bitrate and frame rate of our own stream
autoCameras: boolean; // show other people's cameras as they come on
shareVolume: number; // volume of streams we watch, 0 to 1
@@ -58,6 +60,7 @@ const defaults: Settings = {
theme: 'dark',
publicListUrl: 'https://publist.mumble.info/v1/list?version=1.5.0',
arrangement: 'side',
interface: 'standard',
shareQuality: 'medium',
autoCameras: true,
shareVolume: 1,
+38
View File
@@ -7,6 +7,15 @@
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
];
const INTERFACES: { id: 'standard' | 'rooms'; name: string; note: string }[] = [
{ id: 'standard', name: 'Standard', note: 'Channel tree, chat and members side by side' },
{ id: 'rooms', name: 'Rooms', note: 'One screen at a time, made for phones. New and not complete yet' }
];
function choose(id: 'standard' | 'rooms') {
store.settings.interface = id;
store.saveSettings();
}
function arrange(id: Arrangement) {
store.settings.arrangement = id;
store.saveSettings();
@@ -39,6 +48,30 @@
{/each}
</div>
<h3 class="gap">Interface</h3>
<div class="themes" role="radiogroup" aria-label="Interface">
{#each INTERFACES as f (f.id)}
{@const on = (store.settings.interface ?? 'standard') === f.id}
<button role="radio" aria-checked={on} class:active={on} onclick={() => choose(f.id)} aria-label={f.name}>
<span class="mini face {f.id}" aria-hidden="true">
{#if f.id === 'standard'}
<span class="m-rail"><span></span><span></span></span>
<span class="m-side"><i></i><i class="sel"></i><i></i></span>
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
{:else}
<span class="m-col">
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i></span>
<span class="m-tabs"><i class="sel"></i><i></i><i></i><i></i></span>
</span>
{/if}
</span>
<span class="name">{f.name}</span>
<span class="note">{f.note}</span>
</button>
{/each}
</div>
{#if (store.settings.interface ?? 'standard') === 'standard'}
<h3 class="gap">Layout</h3>
<div class="themes" role="radiogroup" aria-label="Layout">
{#each ARRANGEMENTS as a (a.id)}
@@ -64,6 +97,7 @@
{/each}
</div>
<p class="help">Narrow windows use the phone layout, except Stacked, which stays as it is.</p>
{/if}
<style>
.gap { margin-top: 22px; }
@@ -93,5 +127,9 @@
.m-chat i { height: 4px; background: var(--text-dim); width: 80%; }
.m-chat i.a { background: var(--accent); width: 35%; }
.m-chat i.short { width: 55%; }
.face.rooms .m-col { flex: 1; display: flex; flex-direction: column; }
.m-tabs { flex: none; height: 12px; display: flex; gap: 6px; align-items: center; justify-content: center; background: var(--bg-0); border-top: 1px solid var(--line); }
.m-tabs i { width: 10px; height: 4px; background: var(--text-faint); }
.m-tabs i.sel { background: var(--accent); }
.m-box { margin-top: auto; height: 10px; background: var(--bg-3); border: 1px solid var(--line); }
</style>
+11 -7
View File
@@ -18,7 +18,8 @@
// as are on, and a click enlarges one.
// videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list
// above already shows who is here)
let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props();
// fill: take all the room the parent gives, without a height of its own (the voice screen)
let { always = false, videoOnly = false, fill = false }: { always?: boolean; videoOnly?: boolean; fill?: boolean } = $props();
const self = $derived(session.self);
const channel = $derived(self ? session.channel(self.channelId) : undefined);
@@ -119,13 +120,15 @@
return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34))));
});
let collapsed = $state(load());
let folded = $state(load());
const collapsed = $derived(folded && !fill);
function load(): boolean {
try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; }
}
function toggle() {
collapsed = !collapsed;
try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ }
if (fill) return;
folded = !folded;
try { localStorage.setItem('mumh5.stageCollapsed', folded ? '1' : '0'); } catch { /* storage unavailable */ }
}
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
@@ -349,9 +352,9 @@
{/if}
</section>
{:else if visible && self}
<section class="stage" class:meeting={!!focused} class:collapsed aria-label="Voice channel"
style:height={collapsed ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
{#if !collapsed}
<section class="stage" class:meeting={!!focused} class:collapsed class:fill aria-label="Voice channel"
style:height={collapsed || fill ? undefined : `${focused ? meetingHeight : tilesHeight}px`}>
{#if !collapsed && !fill}
{#if focused}
<ResizeHandle bind:width={meetingHeight} edge="bottom" min={240} max={maxHeight} initial={460} storageKey="mumh5.stageHeightMeeting" label="Resize the meeting view" />
{:else}
@@ -404,6 +407,7 @@
{/if}
<style>
.stage.fill { flex: 1; border-bottom: 0; }
.stage { position: relative; flex: none; display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--line); background: var(--bg-0); }
header { flex: none; display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; background: var(--bg-1); }
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import ChatLog from '../ui/ChatLog.svelte';
import Composer from '../ui/Composer.svelte';
import { session } from '../lib/session.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { stripMark } from '../core/text-signal.ts';
// back: shown where the rooms are a screen of their own
let { back, onback }: { back: boolean; onback: () => void } = $props();
const connected = $derived(session.status === 'connected');
const isDm = $derived(session.view.startsWith('dm:'));
const id = $derived(Number(session.view.slice(3)));
const isText = $derived(!isDm && session.isText(id));
const mine = $derived(!isDm && session.self?.channelId === id);
const title = $derived(connected ? session.viewTitle() : 'Chat');
const topic = $derived(isDm || !connected ? '' : stripMark(session.channel(id)?.description ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim());
const kind = $derived(isDm ? 'Direct message' : isText ? 'Text channel' : mine ? 'Your room' : 'Room you are not in');
function more(e: MouseEvent) {
if (isDm) {
const u = session.user(id);
if (u) menu.show(e, userMenu(u));
} else {
const c = session.channel(id);
if (c) menu.show(e, channelMenu(c));
}
}
</script>
<section class="chat2" aria-label="Chat">
<header>
{#if back}<button class="icon-btn" aria-label="Back to rooms" onclick={onback}><Icon name="back" /></button>{/if}
<div class="title">
<h1><Icon name={isDm ? 'message' : 'hash'} size={18} />{title}</h1>
{#if connected}<span title={topic}>{kind}{topic ? `. ${topic}` : ''}</span>{/if}
</div>
{#if connected}<button class="icon-btn" aria-label="More for {title}" onclick={more}><Icon name="more" /></button>{/if}
</header>
<ChatLog key={session.view}>
{#snippet empty()}
<div class="empty">
{#if connected}
<h2>{isDm ? title : `#${title}`}</h2>
<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 room. Other Mumble clients do not see these messages.'
: mine ? 'Messages go to everyone in this room.'
: 'You are not in this room. Your messages reach the people in it, but you only see theirs after joining.'}</p>
{:else}
<h2>No chat open</h2>
<p>Connect to a server and choose a room.</p>
{/if}
</div>
{/snippet}
</ChatLog>
{#if connected}<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />{/if}
</section>
<style>
.chat2 { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { flex: none; min-height: 60px; display: flex; align-items: center; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--line); }
.title { flex: 1; min-width: 0; padding-left: 8px; }
h1 { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 17px; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
h1 :global(svg) { flex: none; color: var(--text-dim); }
.title span { display: block; font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn { flex: none; width: 40px; height: 40px; }
.empty { padding: 32px 16px 8px; }
.empty h2 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.01em; }
.empty p { margin: 0; color: var(--text-dim); }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import Avatar from '../ui/Avatar.svelte';
import VoiceControls from './VoiceControls.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
// Always in reach: the room you are in, who is talking, and the two buttons that matter
let { onopen }: { onopen: () => void } = $props();
const self = $derived(session.self);
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
const room = $derived(self ? session.channel(self.channelId)?.name || 'Root' : '');
const speakers = $derived(self ? session.usersIn(self.channelId).filter(u => u.session !== self.session && voice.talking[u.session]) : []);
const line = $derived.by(() => {
if (!self) return session.status === 'connecting' ? 'Connecting...' : 'Not connected';
if (voice.error) return 'Voice unavailable';
if (elsewhere) return `Voice is on ${sessions.voiceSession?.server?.label}`;
if (speakers.length) return `${speakers.slice(0, 2).map(u => store.displayName(u)).join(', ')}${speakers.length > 2 ? ` +${speakers.length - 2}` : ''} talking`;
if (voice.settings.mode === 'ptt' && !self.selfMute) return voice.pttDown ? 'Talking' : 'Push to talk';
const n = session.usersIn(self.channelId).length;
return n === 1 ? 'Only you here' : `${n} people here`;
});
</script>
<div class="dock" class:talking={voice.transmitting}>
<button class="where" onclick={onopen} disabled={!self} title="Open the voice screen">
{#if self}<Avatar name={self.name} size={36} speaking={voice.transmitting} />{/if}
<span class="text">
<strong>{self ? room : 'mumh5'}</strong>
<span>{line}</span>
</span>
</button>
{#if elsewhere}
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button>
{:else}
<VoiceControls />
{/if}
</div>
<style>
.dock { flex: none; display: flex; align-items: center; gap: 8px; min-height: 60px; padding: 8px 12px; background: var(--bg-0); border-top: 1px solid var(--line); box-shadow: inset 0 2px 0 transparent; }
.dock.talking { box-shadow: inset 0 2px 0 var(--speaking); }
.where { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; text-align: left; }
.where:disabled { opacity: 1; }
.text { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
.text strong, .text span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.text span { font-size: 13px; color: var(--text-dim); }
.btn { flex: none; }
</style>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session } from '../lib/session.svelte.ts';
import { store } from '../lib/settings.svelte.ts';
import { menu } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
// Everyone on the server, by room; yours first
let { onchat }: { onchat: () => void } = $props();
let query = $state('');
const q = $derived(query.trim().toLowerCase());
const self = $derived(session.self);
const groups = $derived.by(() => {
const byRoom = new Map<number, ReturnType<typeof session.allUsers>>();
for (const u of session.allUsers()) {
if (q && !store.displayName(u).toLowerCase().includes(q) && !u.name.toLowerCase().includes(q)) continue;
if (!byRoom.has(u.channelId)) byRoom.set(u.channelId, []);
byRoom.get(u.channelId)!.push(u);
}
return [...byRoom].map(([id, users]) => ({ id, name: session.channel(id)?.name || 'Root', users }))
.sort((a, b) => Number(b.id === self?.channelId) - Number(a.id === self?.channelId) || a.name.localeCompare(b.name));
});
const total = $derived(session.allUsers().length);
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
</script>
<section class="people" aria-label="People">
<header>
<h1>People</h1>
<span>{session.status === 'connected' ? `${total} online` : 'Not connected'}</span>
</header>
{#if session.status === 'connected'}
<label class="find">
<Icon name="search" size={16} />
<input type="search" placeholder="Find a person" aria-label="Find a person" bind:value={query} />
</label>
<div class="scroll">
{#each groups as g (g.id)}
<h2>{g.name}{#if g.id === self?.channelId} <em>your room</em>{/if} <span>{g.users.length}</span></h2>
<ul>
{#each g.users as u (u.session)}
{@const me = u.session === self?.session}
<li class="entry" role="presentation" oncontextmenu={e => menu.show(e, userMenu(u))}>
<button class="main" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}>
<Avatar name={u.name} size={40} speaking={talking(u.session)} />
<span class="text">
<span class="name" class:talking={talking(u.session)}>{store.displayName(u)}</span>
<span class="sub">{me ? 'You' : u.userId != null ? 'Registered' : 'Guest'}{u.deaf || u.selfDeaf ? ', deafened' : u.mute || u.selfMute || u.suppress ? ', muted' : ''}{session.sharing[u.session] ? `, sharing ${session.sharing[u.session]}` : ''}{u.recording ? ', recording' : ''}</span>
</span>
</button>
{#if !me}
<button class="icon-btn" aria-label="Message {store.displayName(u)}" title="Send a message" onclick={() => { session.selectView(`dm:${u.session}`); onchat(); }}><Icon name="message" size={18} /></button>
{/if}
<button class="icon-btn" aria-label="More for {store.displayName(u)}" onclick={e => menu.show(e, userMenu(u))}><Icon name="more" size={18} /></button>
</li>
{/each}
</ul>
{:else}
<p class="none">Nobody matches "{query}".</p>
{/each}
</div>
{/if}
</section>
<style>
.people { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
header { flex: none; padding: 14px 16px 8px; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; }
header span { font-size: 13px; color: var(--text-dim); }
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
.find:focus-within { border-color: var(--accent); }
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
h2 em { font-style: normal; color: var(--speaking); margin-left: 6px; }
h2 span { margin-left: 6px; font-weight: 400; }
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
.entry:hover { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name.talking { color: var(--speaking); }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.icon-btn { flex: none; width: 40px; height: 40px; }
.none { padding: 12px 8px; color: var(--text-dim); }
</style>
+368
View File
@@ -0,0 +1,368 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { menu, type MenuItem } from '../lib/menu.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { channelMenu, userMenu } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { desktop } from '../lib/native.ts';
import { makeServerIcon } from '../lib/upload.ts';
import { PERM, type Channel } from '../core/client.ts';
import { stripMark } from '../core/text-signal.ts';
// The home screen: servers across the top, then every room of the server with the people
// in it, text channels and direct messages. Rooms are a list, not a tree; where a room
// sits is written under its name.
let { onadd, onedit, onbrowse, onchat }:
{ onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void; onchat: () => void } = $props();
const self = $derived(session.self);
const connected = $derived(session.status === 'connected');
const cert = $derived(session.serverCert);
interface Row { channel: Channel; path: string }
const all = $derived.by(() => {
const out: Row[] = [];
const walk = (parent: number | null, path: string) => {
for (const c of session.childrenOf(parent)) {
out.push({ channel: c, path });
walk(c.id, c.id === 0 ? '' : path ? `${path} / ${c.name}` : c.name);
}
};
walk(null, '');
return out;
});
let query = $state('');
const q = $derived(query.trim().toLowerCase());
const matches = (r: Row) => !q || (r.channel.name || 'Root').toLowerCase().includes(q)
|| session.usersIn(r.channel.id).some(u => store.displayName(u).toLowerCase().includes(q));
// Your own room leads the list; the others keep the server's order
const voiceRooms = $derived(all.filter(r => !session.isText(r.channel.id) && matches(r))
.sort((a, b) => Number(b.channel.id === self?.channelId) - Number(a.channel.id === self?.channelId)));
const textRooms = $derived(all.filter(r => session.isText(r.channel.id) && matches(r)));
const dms = $derived(Object.keys(session.logs).filter(k => k.startsWith('dm:'))
.map(k => ({ key: k as `dm:${number}`, user: session.user(Number(k.slice(3))) }))
.filter(d => !q || (d.user?.name ?? '').toLowerCase().includes(q)));
// A room shows its people when opened; the one you are in is open unless you close it
let opened = $state<Record<number, boolean>>({});
const isOpen = (id: number) => opened[id] ?? (id === self?.channelId || !!q);
// Last thing said in a chat, as plain text
function last(key: string): string {
const e = [...(session.logs[key] ?? [])].reverse().find(x => x.kind === 'text');
if (!e) return '';
const text = e.html.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
return `${e.self ? 'You' : e.author}: ${text || (e.embeds.length ? 'sent a file' : '')}`;
}
const about = (c: Channel) => stripMark(c.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
function openChat(key: `ch:${number}` | `dm:${number}`) {
session.selectView(key);
onchat();
}
function listMenu(e: MouseEvent) {
if (!connected) return;
const noMake = session.can(0, PERM.MakeChannel) === false && session.can(0, PERM.MakeTempChannel) === false;
menu.show(e, [{ label: 'Create channel...', disabled: noMake, action: () => (ui.channelDialog = { mode: 'create', parent: 0 }) }]);
}
function showCert() {
if (!cert?.chain.length) return;
ui.cert = {
title: `Certificate of ${session.server?.host}`, chain: cert.chain,
status: cert.authorized
? { ok: true, text: 'Valid certificate, signed by a trusted authority.' }
: { ok: false, text: `Not signed by a trusted authority (${cert.authError ?? 'unknown reason'}). mumh5 pinned this certificate the first time you connected and warns you if it changes.` }
};
}
// ─── Servers ────────────────────────────────────────────────────────────────
const initials = (label: string) => label.split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?';
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
const unreadOf = (id: string) => {
const s = sessions.byServer(id);
return s && sessions.activeId !== id ? Object.values(s.unread).reduce((a, b) => a + b, 0) : 0;
};
let iconPicker: HTMLInputElement;
let iconFor = '';
async function pickIcon() {
const file = iconPicker.files?.[0];
iconPicker.value = '';
if (file && iconFor) store.setServerIcon(iconFor, await makeServerIcon(file).catch(() => undefined));
}
function serverMenu(e: MouseEvent, s: SavedServer, i: number) {
const state = stateOf(s.id);
const items: MenuItem[] = [
{ header: true, label: s.label },
state === 'idle' ? { label: 'Connect', action: () => sessions.open(s) } : { label: 'Disconnect', action: () => sessions.close(s.id) },
...(state === 'connected' && sessions.voiceId !== s.id ? [{ label: 'Talk here', action: () => { sessions.select(s.id); sessions.setVoice(sessions.byServer(s.id)!); } }] : []),
{ separator: true, label: '' },
{ label: 'Move left', disabled: i === 0, action: () => store.moveServer(s.id, i - 1) },
{ label: 'Move right', disabled: i === store.servers.length - 1, action: () => store.moveServer(s.id, i + 1) },
{ separator: true, label: '' },
{ label: 'Change icon...', action: () => { iconFor = s.id; iconPicker.click(); } },
...(s.icon ? [{ label: 'Remove icon', action: () => store.setServerIcon(s.id, undefined) }] : []),
{ label: 'Edit...', action: () => onedit(s) }
];
menu.show(e, items);
}
</script>
<section class="rooms" aria-label="Rooms">
<nav class="servers" aria-label="Servers">
{#each store.servers as s, i (s.id)}
{@const state = stateOf(s.id)}
{@const unread = unreadOf(s.id)}
<button class="srv" class:active={sessions.activeId === s.id} class:live={state === 'connected'} class:connecting={state === 'connecting'}
class:voice={sessions.voiceId === s.id && state === 'connected'}
title="{s.label} ({s.host}){state === 'connected' ? (sessions.voiceId === s.id ? ', voice' : ', connected') : ''}"
onclick={() => sessions.open(s)} oncontextmenu={e => serverMenu(e, s, i)}>
<span class="pic">{#if s.icon}<img src={s.icon} alt="" draggable="false" />{:else}{initials(s.label)}{/if}</span>
<span class="cap">{s.label}</span>
{#if unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
</button>
{/each}
<input bind:this={iconPicker} type="file" accept="image/*" hidden onchange={pickIcon} />
<button class="srv add" title="Add a server" onclick={onadd}><span class="pic"><Icon name="plus" /></span><span class="cap">Add</span></button>
<!-- The public list and its pings need the desktop app -->
{#if desktop}<button class="srv add" title="Browse public servers" onclick={onbrowse}><span class="pic"><Icon name="globe" /></span><span class="cap">Browse</span></button>{/if}
</nav>
<header class="top">
<button class="place" onclick={showCert} disabled={!cert} title={cert ? (cert.authorized ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
<h1>{session.server?.label ?? 'mumh5'}</h1>
<span class="status">
{#if connected}<span class="dot"></span>Connected{#if session.rtt}, {session.rtt} ms{/if}{#if cert}<Icon name={cert.authorized ? 'lock' : 'shield'} size={12} />{/if}
{:else if session.status === 'connecting'}Connecting...
{:else}Not connected{/if}
</span>
</button>
{#if connected}<button class="icon-btn" title="Server information" aria-label="Server information" onclick={() => (ui.serverInfo = true)}><Icon name="info" size={18} /></button>{/if}
{#if session.status !== 'idle'}
<button class="icon-btn" title="Disconnect" aria-label="Disconnect" onclick={() => session.server && sessions.close(session.server.id)}><Icon name="power" size={18} /></button>
{/if}
</header>
{#if connected}
<label class="find">
<Icon name="search" size={16} />
<input type="search" placeholder="Find a room or a person" aria-label="Find a room or a person" bind:value={query} />
</label>
{/if}
<div class="scroll" role="presentation" oncontextmenu={listMenu}>
{#if connected}
{#if textRooms.length}
<h2>Text channels</h2>
<ul>
{#each textRooms as r (r.channel.id)}
{@const c = r.channel}
{@const key = `ch:${c.id}` as const}
<li class="entry" class:viewing={session.view === key}>
<button class="main" onclick={() => openChat(key)} oncontextmenu={e => menu.show(e, channelMenu(c))}>
<span class="glyph"><Icon name="hash" size={18} /></span>
<span class="text">
<span class="name">{c.name}</span>
<span class="sub">{last(key) || r.path || 'No messages yet'}</span>
</span>
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
</button>
<button class="icon-btn" aria-label="More for {c.name}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
</li>
{/each}
</ul>
{/if}
<h2>Rooms</h2>
<ul>
{#each voiceRooms as r (r.channel.id)}
{@const c = r.channel}
{@const users = session.usersIn(c.id)}
{@const here = self?.channelId === c.id}
{@const open = isOpen(c.id)}
{@const key = `ch:${c.id}` as const}
<li class="room" class:here class:open class:empty={!users.length}>
<div class="entry" role="presentation" oncontextmenu={e => menu.show(e, channelMenu(c))}>
<button class="main" aria-expanded={open} onclick={() => (opened[c.id] = !open)}>
<span class="text">
<span class="name">{#if !c.canEnter}<Icon name="lock" size={14} />{/if}{c.name || 'Root'}</span>
{#if r.path}<span class="sub">in {r.path}</span>{/if}
</span>
{#if users.length && !open}
<span class="faces" aria-label="{users.length} {users.length === 1 ? 'person' : 'people'}">
{#each users.slice(0, 4) as u (u.session)}<Avatar name={u.name} size={26} speaking={talking(u.session)} />{/each}
{#if users.length > 4}<span class="extra">+{users.length - 4}</span>{/if}
</span>
{/if}
{#if session.unread[key]}<span class="badge">{session.unread[key]}</span>{/if}
</button>
{#if here}
<span class="tag">You are here</span>
{:else}
<button class="join" disabled={!c.canEnter} aria-label="Join {c.name || 'Root'}" onclick={() => session.joinChannel(c.id)}>{c.canEnter ? 'Join' : 'Locked'}</button>
{/if}
<button class="icon-btn" aria-label="More for {c.name || 'Root'}" onclick={e => menu.show(e, channelMenu(c))}><Icon name="more" size={18} /></button>
</div>
{#if open}
<div class="inside">
{#if about(c)}<p class="about">{about(c)}</p>{/if}
{#if users.length}
<ul class="people">
{#each users as u (u.session)}
{@const me = u.session === self?.session}
<li>
<button class="person" onclick={() => { session.showUser(u.session); ui.panelOpen = true; }} oncontextmenu={e => menu.show(e, userMenu(u))}>
<Avatar name={u.name} size={32} speaking={talking(u.session)} />
<span class="pname" class:talking={talking(u.session)}>{store.displayName(u)}{#if me} <em>you</em>{/if}</span>
{#if session.sharing[u.session]}<span class="mark live" title="Sharing their {session.sharing[u.session]}"><Icon name={session.sharing[u.session]} size={14} /></span>{/if}
{#if u.recording}<span class="rec">REC</span>{/if}
{#if u.deaf || u.selfDeaf}<span class="mark" class:bad={u.deaf} title={u.deaf ? 'Deafened by server' : 'Deafened'}><Icon name="headphones-off" size={14} /></span>
{:else if u.mute || u.selfMute || u.suppress}<span class="mark" class:bad={u.mute || u.suppress} title={u.mute ? 'Muted by server' : u.suppress ? 'Suppressed' : 'Muted'}><Icon name="mic-off" size={14} /></span>{/if}
</button>
</li>
{/each}
</ul>
{:else}
<p class="nobody">Nobody here.</p>
{/if}
<div class="acts">
<button class="btn" onclick={() => openChat(key)}>Chat</button>
<button class="btn" onclick={() => { session.showChannel(c.id, 'info'); ui.panelOpen = true; }}>Details</button>
</div>
</div>
{/if}
</li>
{:else}
<li class="none">Nothing matches "{query}".</li>
{/each}
</ul>
{#if dms.length}
<h2>Direct messages</h2>
<ul>
{#each dms as dm (dm.key)}
<li class="entry" class:viewing={session.view === dm.key}>
<button class="main" onclick={() => openChat(dm.key)}>
<Avatar name={dm.user?.name ?? '?'} size={32} />
<span class="text">
<span class="name" class:gone={!dm.user}>{dm.user ? store.displayName(dm.user) : 'Offline user'}</span>
<span class="sub">{last(dm.key)}</span>
</span>
{#if session.unread[dm.key]}<span class="badge">{session.unread[dm.key]}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
{:else if session.status === 'idle' && session.disconnectInfo}
{@const d = session.disconnectInfo}
<div class="ended" class:bad={d.kind === 'kicked' || d.kind === 'banned'}>
<h2>
{#if d.kind === 'kicked'}You were kicked{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'banned'}You were banned{d.by ? ` by ${d.by}` : ''}
{:else if d.kind === 'rejected'}The server refused the connection
{:else}Connection lost{/if}
</h2>
{#if d.reason}<p class="reason">{d.reason}</p>{/if}
<button class="btn primary" onclick={() => session.reconnect()}>Reconnect</button>
</div>
{:else if session.status === 'connecting'}
<p class="blank">Connecting...</p>
{:else}
<div class="blank">
{#if session.error}<p class="error">{session.error}</p>{/if}
<p>Choose a server above, or add one.</p>
<div class="acts">
<button class="btn primary" onclick={onadd}>Add a server</button>
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
</div>
</div>
{/if}
</div>
</section>
<style>
.rooms { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
ul { list-style: none; margin: 0; padding: 0; }
.servers { flex: none; display: flex; gap: 4px; padding: 10px 12px 6px; overflow-x: auto; scrollbar-width: none; }
.servers::-webkit-scrollbar { display: none; }
.srv { position: relative; flex: none; width: 64px; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px 0; color: var(--text-dim); }
.pic { position: relative; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); font-weight: 700; color: var(--text); }
.pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cap { max-width: 100%; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.srv.active .pic { outline: 2px solid var(--accent); outline-offset: 2px; }
.srv.active .cap { color: var(--text); }
.srv.live .pic::after, .srv.connecting .pic::after { content: ''; position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; background: var(--speaking); border: 2px solid var(--bg-1); }
.srv.connecting .pic::after { background: var(--text-faint); }
.srv.voice .pic::after { box-shadow: 0 0 0 2px var(--speaking); }
.srv.add .pic { background: transparent; border: 1px dashed var(--line); color: var(--text-dim); }
.srv.add:hover .pic { color: var(--text); border-color: var(--text-dim); }
.count { position: absolute; top: 0; right: 4px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; background: var(--danger); color: #fff; }
.top { flex: none; display: flex; align-items: center; gap: 4px; padding: 6px 8px 6px 0; }
.place { flex: 1; min-width: 0; padding: 4px 16px; text-align: left; }
.place:disabled { opacity: 1; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); }
.dot { width: 8px; height: 8px; background: var(--speaking); }
.find { flex: none; display: flex; align-items: center; gap: 8px; margin: 4px 16px 8px; padding: 0 12px; height: 44px; background: var(--bg-0); border: 1px solid var(--line); color: var(--text-faint); text-transform: none; letter-spacing: 0; font-weight: 400; }
.find:focus-within { border-color: var(--accent); }
.find input { border: 0; background: none; padding: 0; height: 100%; font-size: 15px; }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 16px; }
h2 { margin: 18px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
.entry { display: flex; align-items: center; gap: 4px; min-height: 56px; padding-right: 4px; }
.entry:hover, .entry.viewing { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; }
.glyph { flex: none; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--bg-3); color: var(--text-dim); }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { display: flex; align-items: center; gap: 6px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name.gone { color: var(--text-faint); font-style: italic; }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { flex: none; min-width: 20px; padding: 0 6px; line-height: 20px; text-align: center; font-size: 12px; font-weight: 700; background: var(--danger); color: #fff; }
.room { margin-bottom: 2px; border-left: 3px solid transparent; }
.room.empty .name { color: var(--text-dim); font-weight: 500; }
.room.here { border-left-color: var(--speaking); background: var(--bg-2); }
.room.here .name { color: var(--text); font-weight: 700; }
.faces { flex: none; display: flex; align-items: center; gap: 4px; }
.extra { font-size: 12px; color: var(--text-dim); padding-left: 2px; }
.join { flex: none; height: 36px; padding: 0 14px; font-weight: 600; font-size: 14px; border: 1px solid var(--line); color: var(--text); }
.join:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.tag { flex: none; padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--speaking); }
.icon-btn { flex: none; width: 40px; height: 40px; }
.inside { padding: 0 8px 12px 8px; }
.about { margin: 0 8px 8px; font-size: 14px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.nobody { margin: 0 8px 8px; font-size: 14px; color: var(--text-faint); }
.person { width: 100%; min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 4px 8px; text-align: left; }
.person:hover { background: var(--bg-hover); }
.pname { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pname.talking { color: var(--speaking); }
.pname em { margin-left: 6px; font-style: normal; font-size: 12px; color: var(--text-faint); }
.mark { flex: none; display: inline-flex; color: var(--text-faint); }
.mark.bad { color: var(--danger); }
.mark.live { color: var(--speaking); }
.rec { flex: none; font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
.acts { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 8px 0; }
.none { padding: 12px 8px; color: var(--text-dim); }
.blank { padding: 24px 16px; color: var(--text-dim); }
.blank p { margin: 0 0 4px; }
.blank .acts { margin: 14px 0 0; }
.error { color: var(--danger); }
.ended { margin: 16px 8px; padding: 16px; background: var(--bg-2); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
.ended.bad { border-left-color: var(--danger); }
.ended h2 { margin: 0 0 6px; font-size: 18px; text-transform: none; letter-spacing: 0; color: var(--text); }
.ended .reason { margin: 0 0 12px; overflow-wrap: anywhere; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import RightPanel from '../ui/RightPanel.svelte';
import Rooms from './Rooms.svelte';
import ChatScreen from './ChatScreen.svelte';
import VoiceScreen from './VoiceScreen.svelte';
import People from './People.svelte';
import You from './You.svelte';
import Dock from './Dock.svelte';
import { session } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import type { SavedServer } from '../lib/settings.svelte.ts';
// The Rooms interface: one screen at a time with a bar to move between them, made for
// phones first. Wide windows keep the rooms on the left next to the chosen screen. Same
// state, dialogs and menus as the standard interface; only the arrangement differs.
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
type Screen = 'rooms' | 'chat' | 'voice' | 'people' | 'you';
let width = $state(window.innerWidth);
const wide = $derived(width >= 900);
let chosen = $state<Screen>('rooms');
// With the rooms always on the left, their place in the main area goes to the chat
const screen = $derived(wide && chosen === 'rooms' ? 'chat' : chosen);
const connected = $derived(session.status === 'connected');
const unread = $derived(Object.values(session.unread).reduce((a, b) => a + b, 0));
const inVoice = $derived(!!session.self);
const ITEMS: { id: Screen; name: string; icon: string }[] = [
{ id: 'rooms', name: 'Rooms', icon: 'rooms' },
{ id: 'chat', name: 'Chat', icon: 'message' },
{ id: 'voice', name: 'Voice', icon: 'channel' },
{ id: 'people', name: 'People', icon: 'users' },
{ id: 'you', name: 'You', icon: 'user' }
];
const items = $derived(wide ? ITEMS.filter(i => i.id !== 'rooms') : ITEMS);
// A direct message or a text channel opened from a menu shows its chat. Joining a room
// also changes the chat, but leaves you where you are.
let lastView = session.view;
$effect(() => {
const view = session.view;
if (view === lastView) return;
lastView = view;
if (view.startsWith('dm:') || session.isText(Number(view.slice(3)))) chosen = 'chat';
});
// Profiles and channel details open over the screen
const sheet = $derived(ui.panelOpen && connected && session.panel.kind !== 'members');
function closeSheet() {
ui.panelOpen = false;
session.closePanel();
}
</script>
<svelte:window bind:innerWidth={width} onkeydown={e => { if (e.key === 'Escape' && sheet) closeSheet(); }} />
<div class="shell" class:wide>
<nav class="bar" aria-label="Screens">
{#each items as item (item.id)}
<button class="item" class:active={screen === item.id} aria-current={screen === item.id ? 'page' : undefined} onclick={() => (chosen = item.id)}>
<span class="glyph">
<Icon name={item.icon} size={22} />
{#if item.id === 'chat' && unread}<span class="count">{unread > 99 ? '99+' : unread}</span>{/if}
{#if item.id === 'voice' && inVoice}<span class="on"></span>{/if}
</span>
<span class="cap">{item.name}</span>
</button>
{/each}
</nav>
{#if wide}
<div class="side">
<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
<Dock onopen={() => (chosen = 'voice')} />
</div>
{/if}
<main>
<div class="screen">
{#if screen === 'rooms'}<Rooms {onadd} {onedit} {onbrowse} onchat={() => (chosen = 'chat')} />
{:else if screen === 'chat'}<ChatScreen back={!wide} onback={() => (chosen = 'rooms')} />
{:else if screen === 'voice'}<VoiceScreen onrooms={() => (chosen = 'rooms')} onchat={() => (chosen = 'chat')} />
{:else if screen === 'people'}<People onchat={() => (chosen = 'chat')} />
{:else}<You {onadd} {onedit} {onbrowse} />{/if}
</div>
{#if !wide && screen !== 'voice'}<Dock onopen={() => (chosen = 'voice')} />{/if}
</main>
{#if sheet}
<button class="scrim" aria-label="Close" onclick={closeSheet}></button>
<div class="sheet"><RightPanel onnavigate={() => { closeSheet(); chosen = 'chat'; }} onclose={closeSheet} /></div>
{/if}
</div>
<style>
.shell { position: relative; height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--bg-1); }
main { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
.screen { flex: 1; min-height: 0; }
/* Phones: the bar is at the bottom, under the thumb */
.bar { order: 2; flex: none; display: flex; background: var(--bg-0); border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom); }
.item { flex: 1; min-width: 0; height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; color: var(--text-faint); box-shadow: inset 0 2px 0 transparent; }
.item:hover { color: var(--text); }
.item.active { color: var(--text); box-shadow: inset 0 2px 0 var(--accent); }
.glyph { position: relative; display: flex; }
.cap { font-size: 11px; font-weight: 600; }
.count { position: absolute; top: -6px; left: 14px; min-width: 18px; padding: 0 4px; line-height: 16px; font-size: 10px; font-weight: 700; text-align: center; background: var(--danger); color: #fff; }
.on { position: absolute; top: -2px; right: -4px; width: 8px; height: 8px; background: var(--speaking); }
/* Wide windows: the bar on the left edge, the rooms next to it, the screen takes the rest */
.shell.wide { flex-direction: row; }
.wide .bar { order: 0; flex-direction: column; width: 76px; border-top: 0; border-right: 1px solid var(--line); padding: 8px 0; }
.wide .item { flex: none; height: 64px; box-shadow: inset 2px 0 0 transparent; }
.wide .item.active { box-shadow: inset 2px 0 0 var(--accent); }
.side { flex: none; width: 360px; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.side > :global(.rooms) { flex: 1; }
.scrim { position: absolute; inset: 0; z-index: 15; background: rgb(0 0 0 / 0.55); }
.sheet { position: absolute; z-index: 20; left: 0; right: 0; bottom: 0; height: min(86%, 720px); display: flex; box-shadow: 0 -8px 32px rgb(0 0 0 / 0.4); border-top: 1px solid var(--line); }
.sheet :global(.panel) { width: 100% !important; border-left: 0; }
.sheet :global(.panel > .resize) { display: none; }
.wide .sheet { left: auto; top: 0; height: auto; width: 400px; border-top: 0; box-shadow: -8px 0 32px rgb(0 0 0 / 0.4); }
</style>
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { share } from '../lib/share.svelte.ts';
// Mute, deafen and share. large: the voice screen's row of big buttons with labels.
let { large = false }: { large?: boolean } = $props();
const self = $derived(session.self);
// Without a connection the buttons show the remembered choice, applied at the next connect
const muted = $derived(self ? self.selfMute : sessions.mute);
const deafened = $derived(self ? self.selfDeaf : sessions.deaf);
const sharing = $derived(!!share.stream);
const canShare = $derived(session.status === 'connected' && share.supported(sessions.active));
function quick(e: MouseEvent, kind: 'input' | 'output') {
e.preventDefault();
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
ui.quickAudio = { kind, x: r.left + r.width / 2, y: r.top };
}
</script>
<div class="controls" class:large>
<button class:on={muted} aria-label={muted ? 'Unmute' : 'Mute'} title={muted ? 'Unmute (hold or right-click for microphone options)' : 'Mute (hold or right-click for microphone options)'}
onclick={() => session.setSelfMute(!muted)} oncontextmenu={e => quick(e, 'input')}>
<Icon name={muted ? 'mic-off' : 'mic'} size={large ? 24 : 20} />
{#if large}<span>{muted ? 'Muted' : 'Mute'}</span>{/if}
</button>
<button class:on={deafened} aria-label={deafened ? 'Undeafen' : 'Deafen'} title={deafened ? 'Undeafen (hold or right-click for output options)' : 'Deafen (hold or right-click for output options)'}
onclick={() => session.setSelfDeaf(!deafened)} oncontextmenu={e => quick(e, 'output')}>
<Icon name={deafened ? 'headphones-off' : 'headphones'} size={large ? 24 : 20} />
{#if large}<span>{deafened ? 'Deafened' : 'Deafen'}</span>{/if}
</button>
{#if large}
<button class:live={sharing} disabled={!sharing && !canShare} aria-label={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera'}
title={sharing ? `Stop sharing your ${share.kind}` : 'Share your screen or camera with your channel'}
onclick={() => (sharing ? share.stop() : share.start(sessions.active))}>
<Icon name={sharing ? share.kind : 'screen'} size={24} />
<span>{sharing ? 'Stop sharing' : 'Share'}</span>
</button>
{/if}
</div>
<style>
.controls { display: flex; gap: 4px; }
button { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; color: var(--text); background: var(--bg-3); }
button:hover:not(:disabled) { background: var(--bg-active); }
button.on { background: var(--danger); color: #fff; }
button.live { background: var(--speaking); color: var(--bg-0); }
.large { gap: 10px; justify-content: center; }
.large button { width: 96px; height: 72px; flex-direction: column; gap: 6px; font-size: 13px; font-weight: 600; }
</style>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
import VoiceStage from '../ui/VoiceStage.svelte';
import VoiceControls from './VoiceControls.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
// The room you are in as a call: everyone as tiles, streams large, the controls underneath
let { onrooms, onchat }: { onrooms: () => void; onchat: () => void } = $props();
const self = $derived(session.self);
const room = $derived(self ? session.channel(self.channelId) : undefined);
const elsewhere = $derived(!!self && !!sessions.voiceSession && sessions.voiceSession !== sessions.active);
const transport = $derived(session.voiceTransport);
</script>
<section class="voice" aria-label="Voice">
{#if self}
<header>
<div class="title">
<h1>{room?.name || 'Root'}</h1>
<span>{session.usersIn(self.channelId).length} here, voice over {transport.udp ? 'UDP' : 'TCP'}{#if voice.error}, voice unavailable{/if}</span>
</div>
<button class="btn" onclick={() => { session.selectView(`ch:${self.channelId}`); onchat(); }}>Chat</button>
</header>
{#if voice.error}<p class="note bad">{voice.error}</p>{/if}
{#if elsewhere}
<p class="note">Voice is on {sessions.voiceSession?.server?.label}; you are deafened here.
<button class="btn" onclick={() => sessions.setVoice(sessions.active)}>Talk here</button></p>
{/if}
<div class="stage"><VoiceStage always fill /></div>
<footer><VoiceControls large /></footer>
{:else}
<div class="blank">
<h1>Voice</h1>
<p>Connect to a server and join a room to talk.</p>
<button class="btn primary" onclick={onrooms}>Go to rooms</button>
</div>
{/if}
</section>
<style>
.voice { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-2); }
header { flex: none; display: flex; align-items: center; gap: 12px; padding: 14px 16px 10px; }
.title { flex: 1; min-width: 0; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.title span { font-size: 13px; color: var(--text-dim); }
.note { flex: none; display: flex; align-items: center; gap: 12px; margin: 0 16px 8px; padding: 8px 12px; font-size: 14px; color: var(--text-dim); background: var(--bg-0); border: 1px solid var(--line); }
.note.bad { color: var(--danger); }
.stage { flex: 1; min-height: 0; display: flex; flex-direction: column; }
footer { flex: none; padding: 12px 16px 16px; border-top: 1px solid var(--line); background: var(--bg-1); }
.blank { padding: 32px 16px; color: var(--text-dim); }
.blank h1 { color: var(--text); }
.blank p { margin: 6px 0 14px; }
</style>
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts">
import Icon from '../ui/Icon.svelte';
import Avatar from '../ui/Avatar.svelte';
import { session, sessions } from '../lib/session.svelte.ts';
import { store, type SavedServer } from '../lib/settings.svelte.ts';
import { ui } from '../lib/ui.svelte.ts';
import { changeNamePrompt } from '../lib/actions.ts';
import { voice } from '../lib/audio/voice.svelte.ts';
import { desktop } from '../lib/native.ts';
// Yourself, your servers and the settings, in one place
let { onadd, onedit, onbrowse }: { onadd: () => void; onedit: (s: SavedServer) => void; onbrowse: () => void } = $props();
const self = $derived(session.self);
const stateOf = (id: string) => sessions.byServer(id)?.status ?? 'idle';
type Tab = typeof ui.settingsTab;
const SETTINGS: { tab: Tab; name: string; note: string }[] = [
{ tab: 'voice', name: 'Voice', note: 'Microphone, speakers, how you transmit' },
{ tab: 'sounds', name: 'Sounds', note: 'Notification sounds' },
{ tab: 'appearance', name: 'Appearance', note: 'Colors and interface' },
{ tab: 'accessibility', name: 'Accessibility', note: '' },
{ tab: 'files', name: 'Chat and files', note: 'Uploads, previews, embeds' },
{ tab: 'identities', name: 'Identities', note: 'Your certificates' }
];
const openSettings = (tab: Tab) => { ui.settingsTab = tab; ui.settingsOpen = true; };
function profile(edit: boolean) {
if (!self) return;
session.showUser(self.session);
if (edit) ui.editDescription = true;
ui.panelOpen = true;
}
</script>
<section class="you" aria-label="You">
<div class="scroll">
<div class="card">
{#if self}
<Avatar name={self.name} size={64} speaking={voice.transmitting} />
<div class="who">
<h1>{self.name}</h1>
<span>{self.userId != null ? 'Registered' : 'Guest'} on {session.server?.label}, in {session.channel(self.channelId)?.name || 'Root'}</span>
</div>
{:else}
<div class="who">
<h1>You</h1>
<span>{session.status === 'connecting' ? 'Connecting...' : 'Not connected'}</span>
</div>
{/if}
</div>
{#if self}
<div class="acts">
<button class="btn" onclick={() => profile(false)}>Your profile</button>
<button class="btn" onclick={() => profile(true)}>Change your description</button>
<button class="btn" onclick={() => changeNamePrompt(self)}>Change name</button>
{#if self.userId == null}<button class="btn" onclick={() => session.register(self.session)}>Register on this server</button>{/if}
<button class="btn" onclick={() => (ui.userInfo = self.session)}>Connection information</button>
</div>
{/if}
<h2>Servers</h2>
<ul>
{#each store.servers as s (s.id)}
{@const state = stateOf(s.id)}
<li class="entry">
<button class="main" onclick={() => sessions.open(s)}>
<span class="text">
<span class="name">{s.label}{#if state !== 'idle'}<em>{state === 'connecting' ? 'connecting' : sessions.voiceId === s.id ? 'connected, voice' : 'connected'}</em>{/if}</span>
<span class="sub">{s.host}:{s.port} as {s.username}</span>
</span>
</button>
{#if state !== 'idle'}<button class="btn small" onclick={() => sessions.close(s.id)}>Disconnect</button>{/if}
<button class="btn small" onclick={() => onedit(s)}>Edit</button>
</li>
{/each}
</ul>
<div class="acts">
<button class="btn" onclick={onadd}>Add a server</button>
{#if desktop}<button class="btn" onclick={onbrowse}>Browse public servers</button>{/if}
{#if session.status === 'connected'}<button class="btn" onclick={() => (ui.serverInfo = true)}>Server information</button>{/if}
</div>
<h2>Settings</h2>
<ul>
{#each SETTINGS as item (item.tab)}
<li class="entry">
<button class="main" onclick={() => openSettings(item.tab)}>
<span class="text">
<span class="name">{item.name}</span>
{#if item.note}<span class="sub">{item.note}</span>{/if}
</span>
<Icon name="arrow-right" size={16} />
</button>
</li>
{/each}
</ul>
</div>
</section>
<style>
.you { height: 100%; min-height: 0; display: flex; flex-direction: column; background: var(--bg-1); }
.scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 0 8px 24px; }
.card { display: flex; align-items: center; gap: 16px; padding: 20px 8px 12px; }
.who { min-width: 0; }
h1 { margin: 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; overflow-wrap: anywhere; }
.who span { font-size: 13px; color: var(--text-dim); }
.acts { display: flex; flex-wrap: wrap; gap: 8px; padding: 4px 8px; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { margin: 24px 8px 6px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
.entry { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-right: 8px; }
.entry:hover { background: var(--bg-hover); }
.main { flex: 1; min-width: 0; align-self: stretch; display: flex; align-items: center; gap: 12px; padding: 8px; text-align: left; color: var(--text-dim); }
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name em { margin-left: 8px; font-style: normal; font-size: 12px; font-weight: 600; color: var(--speaking); }
.sub { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.small { flex: none; padding: 6px 12px; font-size: 13px; }
</style>