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:
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user