- Channels: create, edit, delete, link and move (drag and drop for channels and people), right-click on free space in the channel list - Permission editor with inherited rules, allow/deny per permission, groups and members; menus grey out actions the server does not allow - Notification sounds for connection, channel, chat, mute and push-to-talk events; custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB - Color schemes: Dark, Light, Midnight, Frost and Windows 95 - Public server list with live UDP ping, fetched through Chromium's network stack - Kick, ban and connection-lost card with Reconnect - Right-click menus for text fields, links, images and message authors - Formatting toolbar in the message box, shared with the description editor - Resizable channel list, menu headers, registration from the self menu only - Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly, so quick actions are no longer dropped silently Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
9.4 KiB
Svelte
180 lines
9.4 KiB
Svelte
<script lang="ts">
|
|
import Icon from './Icon.svelte';
|
|
import Avatar from './Avatar.svelte';
|
|
import Members from './Members.svelte';
|
|
import ResizeHandle from './ResizeHandle.svelte';
|
|
import ChatLog from './ChatLog.svelte';
|
|
import Composer from './Composer.svelte';
|
|
import { session } from '../lib/session.svelte.ts';
|
|
import { store } from '../lib/settings.svelte.ts';
|
|
import { menu } from '../lib/menu.svelte.ts';
|
|
import { userMenu, changeNamePrompt } from '../lib/actions.ts';
|
|
import { voice } from '../lib/audio/voice.svelte.ts';
|
|
import { renderIncoming } from '../lib/html.ts';
|
|
import RichEditor from './RichEditor.svelte';
|
|
|
|
let { onnavigate, onclose }: { onnavigate: () => void; onclose: () => void } = $props();
|
|
|
|
const panel = $derived(session.panel);
|
|
const channel = $derived(panel.kind === 'channel' ? session.channel(panel.id) : undefined);
|
|
const user = $derived(panel.kind === 'user' ? session.user(panel.session) : undefined);
|
|
const isSelf = $derived(!!user && user.session === session.self?.session);
|
|
|
|
// Description or comment arrive later when they were only announced by hash
|
|
// Re-runs when the hash changes, e.g. right after saving a long description
|
|
$effect(() => { if (channel?.descriptionHash && !channel.description) session.loadDescription(channel.id); });
|
|
$effect(() => { if (user?.commentHash && !user.comment) session.loadComment(user.session); });
|
|
|
|
const description = $derived(channel?.description ? renderIncoming(channel.description, store.trustedHosts).html : '');
|
|
const comment = $derived(user?.comment ? renderIncoming(user.comment, store.trustedHosts).html : '');
|
|
const pendingDescription = $derived(!!channel?.descriptionHash && !channel.description);
|
|
const pendingComment = $derived(!!user?.commentHash && !user.comment);
|
|
|
|
// User-resizable width, remembered between sessions
|
|
const MIN_W = 240, DEFAULT_W = 300;
|
|
const maxW = () => Math.max(MIN_W, Math.min(900, window.innerWidth - 420));
|
|
let width = $state(Math.max(MIN_W, Number(localStorage.getItem('mumh5.panelWidth')) || DEFAULT_W));
|
|
|
|
|
|
let editing = $state(false);
|
|
let draft = $state('');
|
|
$effect(() => { void panel; editing = false; });
|
|
|
|
function startEdit() {
|
|
draft = user?.comment ?? '';
|
|
editing = true;
|
|
}
|
|
|
|
function saveComment() {
|
|
session.setComment(draft);
|
|
editing = false;
|
|
}
|
|
|
|
</script>
|
|
|
|
<aside class="panel" aria-label="Details" style:width="{width}px">
|
|
<ResizeHandle bind:width edge="left" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.panelWidth" label="Resize panel" />
|
|
{#if panel.kind === 'members' || (!channel && !user)}
|
|
<header><h2>Members</h2><button class="icon-btn" onclick={onclose} aria-label="Close"><Icon name="x" size={18} /></button></header>
|
|
<Members {onnavigate} />
|
|
{:else if channel && panel.kind === 'channel'}
|
|
<header>
|
|
<h2>{channel.name || 'Root'}</h2>
|
|
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
|
|
</header>
|
|
<div class="tabs" role="tablist">
|
|
<button role="tab" aria-selected={panel.tab === 'info'} class:active={panel.tab === 'info'} onclick={() => session.showChannel(channel.id, 'info')}>Info</button>
|
|
<button role="tab" aria-selected={panel.tab === 'chat'} class:active={panel.tab === 'chat'} onclick={() => session.showChannel(channel.id, 'chat')}>
|
|
Chat{#if session.unread[`ch:${channel.id}`]}<span class="badge">{session.unread[`ch:${channel.id}`]}</span>{/if}
|
|
</button>
|
|
</div>
|
|
{#if panel.tab === 'info'}
|
|
<div class="body">
|
|
{#if description}
|
|
<div class="html">{@html description}</div>
|
|
{:else if pendingDescription}
|
|
<p class="dim">Loading description...</p>
|
|
{:else}
|
|
<p class="dim">This channel has no description.</p>
|
|
{/if}
|
|
<p class="dim small">{session.usersIn(channel.id).length} here, {session.subtreeUserCount(channel.id)} including subchannels{channel.maxUsers ? `, limit ${channel.maxUsers}` : ''}{channel.temporary ? ', temporary' : ''}</p>
|
|
<div class="actions">
|
|
{#if session.self?.channelId === channel.id}
|
|
<span class="here">You are in this channel</span>
|
|
{:else}
|
|
<button class="btn primary" disabled={!channel.canEnter} onclick={() => { session.joinChannel(channel.id); onnavigate(); }}>
|
|
{channel.canEnter ? 'Join channel' : 'No access'}
|
|
</button>
|
|
{/if}
|
|
<button class="btn" onclick={() => session.showChannel(channel.id, 'chat')}>Open chat</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<ChatLog key={`ch:${channel.id}`}>
|
|
{#snippet empty()}<p class="dim pad">Messages sent here go to everyone in #{channel.name || 'Root'}, even if you are not in it.</p>{/snippet}
|
|
</ChatLog>
|
|
<Composer target={`ch:${channel.id}`} placeholder={`Message #${channel.name || 'Root'}`} />
|
|
{/if}
|
|
{:else if user}
|
|
<header>
|
|
<h2>{isSelf ? 'Your profile' : 'Profile'}</h2>
|
|
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
|
|
</header>
|
|
<div class="body">
|
|
<div class="who" role="presentation" oncontextmenu={e => menu.show(e, userMenu(user))}>
|
|
<Avatar name={user.name} size={56} speaking={voice.talking[user.session] || (isSelf && voice.transmitting)} />
|
|
<div class="who-text">
|
|
<h3>{store.displayName(user)}</h3>
|
|
<p class="where" title="Current channel"><Icon name="channel" size={14} /><span>{session.channel(user.channelId)?.name || 'Root'}</span></p>
|
|
{#if store.displayName(user) !== user.name}<p class="dim small">Real name: {user.name}</p>{/if}
|
|
<p class="flags">
|
|
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
|
|
{#if user.selfDeaf || user.deaf}<span>{user.deaf ? 'server deafened' : 'deafened'}</span>{/if}
|
|
{#if user.prioritySpeaker}<span>priority speaker</span>{/if}
|
|
{#if user.recording}<span class="bad">recording</span>{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isSelf}
|
|
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
|
{/if}
|
|
|
|
<h4>Description</h4>
|
|
{#if editing}
|
|
<RichEditor value={draft} limit={session.htmlLimit} placeholder="Tell people about yourself" onchange={h => (draft = h)} />
|
|
<div class="actions">
|
|
<button class="btn primary" onclick={saveComment}>Save</button>
|
|
<button class="btn" onclick={() => (editing = false)}>Cancel</button>
|
|
</div>
|
|
{:else}
|
|
{#if comment}
|
|
<div class="html">{@html comment}</div>
|
|
{:else if pendingComment}
|
|
<p class="dim">Loading description...</p>
|
|
{:else}
|
|
<p class="dim">{isSelf ? 'You have no description yet.' : 'No description.'}</p>
|
|
{/if}
|
|
{#if isSelf}<div class="actions"><button class="btn" onclick={startEdit}>Edit description</button></div>{/if}
|
|
{/if}
|
|
|
|
|
|
{#if !isSelf}
|
|
<div class="actions">
|
|
<button class="btn primary" onclick={() => { session.selectView(`dm:${user.session}`); onnavigate(); }}>Send message</button>
|
|
<button class="btn" onclick={e => menu.show(e, userMenu(user))}>More</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</aside>
|
|
|
|
<style>
|
|
.panel { position: relative; max-width: 100%; height: 100%; flex: none; display: flex; flex-direction: column; background: var(--bg-1); border-left: 1px solid var(--line); min-height: 0; }
|
|
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; border-bottom: 1px solid var(--line); color: var(--text-dim); }
|
|
h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.tabs { display: flex; flex: none; border-bottom: 1px solid var(--line); }
|
|
.tabs button { flex: 1; padding: 10px; color: var(--text-dim); font-weight: 600; font-size: 14px; display: inline-flex; justify-content: center; gap: 6px; }
|
|
.tabs button.active { color: var(--text); box-shadow: inset 0 -2px 0 var(--accent); }
|
|
.badge { background: var(--danger); color: #fff; font-size: 11px; padding: 0 5px; line-height: 16px; }
|
|
.body { flex: 1; overflow-y: auto; padding: 14px; }
|
|
.pad { padding: 14px; }
|
|
.dim { color: var(--text-dim); margin: 0; }
|
|
.small { font-size: 13px; margin-top: 8px; }
|
|
.html { overflow-wrap: anywhere; user-select: text; }
|
|
.html :global(img) { max-width: 100%; }
|
|
.html :global(p) { margin: 0 0 6px; }
|
|
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; align-items: center; }
|
|
.here { color: var(--speaking); font-weight: 600; font-size: 14px; }
|
|
.who { display: flex; gap: 14px; align-items: center; }
|
|
.who-text { min-width: 0; }
|
|
.where { display: flex; align-items: center; gap: 5px; margin: 3px 0 0; font-size: 13px; color: var(--text-dim); min-width: 0; }
|
|
.where :global(svg) { flex: none; }
|
|
.where span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
|
|
h4 { margin: 22px 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
|
|
.flags { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 0; }
|
|
.flags span { font-size: 11px; padding: 0 6px; border: 1px solid var(--line); color: var(--text-dim); }
|
|
.flags .bad { border-color: var(--danger); color: var(--danger); }
|
|
</style>
|