Text channels #18
@@ -6,6 +6,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`.
|
||||
|
||||
### Added
|
||||
|
||||
- A second interface, "Rooms" (Settings, Appearance, Interface), made for phones first: one screen at a time with a bar for Rooms, Chat, Voice, People and You. Rooms are a list with the people inside shown inline and a Join button; your room and a mute and deafen dock stay in reach; the voice screen shows the room as tiles with large controls. Wide windows keep the rooms next to the chosen screen. It uses the same dialogs and menus as the standard interface. Not in it yet: moving channels and people by dragging.
|
||||
- Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them.
|
||||
- Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it.
|
||||
- Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there.
|
||||
|
||||
@@ -49,6 +49,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
|
||||
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets); where a browser has no WebCodecs audio it uses libopus in WebAssembly (`audio/opus-wasm.ts`, `localStorage mumh5.forceWasmOpus = 1` forces it). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
|
||||
- Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback).
|
||||
- `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store).
|
||||
- `src/ui2/`: the Rooms interface (`settings.interface === 'rooms'`), a second shell on the same state: `Shell.svelte` (screens and bar, rooms beside the screen from 900px), `Rooms`, `ChatScreen`, `VoiceScreen`, `People`, `You`, `Dock`. It reuses `ChatLog`, `Composer`, `VoiceStage` (`fill`), `RightPanel` (as a sheet) and every dialog and menu from `src/ui/`. A feature added to one interface needs its place in the other. Class names there must not collide with ones `themes.css` styles (`.tabs`, `.rail`, `.tile`).
|
||||
|
||||
- Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:<id>` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`.
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim
|
||||
- System-wide push to talk
|
||||
- Whisper and shout
|
||||
- Text channels: private ones, and history while nobody is online (needs a helper on the server)
|
||||
- A second, mobile-first interface with the same features in a different layout
|
||||
- Rooms interface: moving channels and people without dragging, gestures, more polish
|
||||
- Rich chat between mumh5 users: replies, reactions, edits, typing indicators
|
||||
- Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8)
|
||||
- Release builds for all platforms
|
||||
|
||||
@@ -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>
|
||||
@@ -1109,6 +1109,90 @@ try {
|
||||
await page.keyboard.press('Escape');
|
||||
console.log('ok: registered on the server');
|
||||
|
||||
// ─── The Rooms interface: the same app arranged for phones ─────────────────
|
||||
{
|
||||
const before = page.viewportSize()!;
|
||||
await page.getByTitle('Settings').click();
|
||||
await page.getByRole('tab', { name: 'Appearance' }).click();
|
||||
await page.getByRole('radio', { name: 'Rooms' }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
const nav = page.getByRole('navigation', { name: 'Screens' });
|
||||
const rooms = page.getByRole('region', { name: 'Rooms' });
|
||||
await rooms.getByRole('heading', { name: 'Test Server' }).waitFor();
|
||||
assert.equal(await page.locator('.sidebar').count(), 0, 'the standard interface is gone');
|
||||
// People show in their room without opening it; the dock names the room you are in
|
||||
await rooms.locator('.room.here .person', { hasText: 'bob' }).waitFor();
|
||||
await page.locator('.dock', { hasText: 'Root' }).waitFor();
|
||||
if (admin) {
|
||||
await rooms.getByRole('button', { name: 'Join Games' }).click();
|
||||
await until2('alice in Games', () => bob.channels.get(userState(newName)!.channelId)?.name === 'Games');
|
||||
await page.locator('.dock', { hasText: 'Games' }).waitFor();
|
||||
await rooms.getByRole('button', { name: 'Join Root' }).click();
|
||||
await until2('alice back in Root', () => userState(newName)!.channelId === 0);
|
||||
await page.locator('.dock', { hasText: 'Root' }).waitFor();
|
||||
}
|
||||
console.log('ok: rooms interface lists rooms with their people and joins from the list');
|
||||
|
||||
// Mute from the dock
|
||||
const wasMuted = !!userState(newName)!.selfMute;
|
||||
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Unmute' : 'Mute', exact: true }).click();
|
||||
await until2('mute toggled from the dock', () => !!userState(newName)!.selfMute === !wasMuted);
|
||||
await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Mute' : 'Unmute', exact: true }).click();
|
||||
await until2('mute restored', () => !!userState(newName)!.selfMute === wasMuted);
|
||||
|
||||
// Chat is a screen of its own
|
||||
await nav.getByRole('button', { name: 'Chat' }).click();
|
||||
const roomsMsg = new Promise<void>(res => bob.on('text', m => { if (m.html === 'hello from rooms') res(); }));
|
||||
await page.locator('.chat2 .composer [contenteditable]').fill('hello from rooms');
|
||||
await page.locator('.chat2 .composer [contenteditable]').press('Enter');
|
||||
await within(roomsMsg, 'message from the rooms interface');
|
||||
await page.locator('.chat2 .msg', { hasText: 'hello from rooms' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Back to rooms' }).click();
|
||||
await rooms.waitFor();
|
||||
|
||||
// The voice screen shows the room as tiles with the large controls
|
||||
await nav.getByRole('button', { name: 'Voice' }).click();
|
||||
const voiceScreen = page.getByRole('region', { name: 'Voice', exact: true });
|
||||
await voiceScreen.locator('.tile', { hasText: 'bob' }).waitFor();
|
||||
await voiceScreen.getByRole('button', { name: /^Share your screen or camera$/ }).waitFor();
|
||||
assert.equal(await page.locator('.dock').count(), 0, 'no dock on the voice screen');
|
||||
|
||||
// People: a profile opens over the screen, a message opens the chat
|
||||
await nav.getByRole('button', { name: 'People' }).click();
|
||||
const people = page.getByRole('region', { name: 'People' });
|
||||
await people.locator('.entry', { hasText: 'bob' }).locator('.main').click();
|
||||
await page.locator('.sheet h3', { hasText: 'bob' }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.locator('.sheet').waitFor({ state: 'detached' });
|
||||
await people.getByRole('button', { name: 'Message bob' }).click();
|
||||
await page.locator('.chat2 h1', { hasText: 'bob' }).waitFor();
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-phone.png') });
|
||||
|
||||
// Wide windows keep the rooms next to the screen
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await rooms.waitFor();
|
||||
await page.locator('.chat2').waitFor();
|
||||
assert.equal(await nav.getByRole('button', { name: 'Rooms' }).count(), 0);
|
||||
if (notesId != null) {
|
||||
await rooms.locator('.entry', { hasText: 'notes' }).locator('.main').click();
|
||||
await page.locator('.chat2 .msg', { hasText: 'text hello' }).waitFor();
|
||||
}
|
||||
if (shots) await page.screenshot({ path: path.join(shots, 'rooms-wide.png') });
|
||||
console.log('ok: rooms interface: dock, chat, voice screen, people, profile sheet, wide layout');
|
||||
|
||||
// Back to the standard interface from the You screen
|
||||
await nav.getByRole('button', { name: 'You' }).click();
|
||||
await page.getByRole('region', { name: 'You' }).getByRole('button', { name: /^Appearance/ }).click();
|
||||
await page.getByRole('radio', { name: 'Standard' }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.locator('.sidebar').waitFor();
|
||||
await page.setViewportSize(before);
|
||||
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
|
||||
await page.locator('.chat h1', { hasText: 'Root' }).waitFor();
|
||||
console.log('ok: back to the standard interface from the You screen');
|
||||
}
|
||||
|
||||
// Kicked: the reason is shown with a Reconnect button
|
||||
if (admin) {
|
||||
const me = [...admin.users.values()].find(u => u.name === newName)!;
|
||||
|
||||
Reference in New Issue
Block a user