- Link previews for web links in chat, fetched by the f0ckm upload host by default (linked sites never see the user's IP), or directly, or off - Voice channel tiles above the chat on windows too narrow for the member list - Per-person volume slider in the user context menu - Profile panel: channel on its own line under the name, no Guest badge, voice actions moved to the context menu - User information dialog refreshes every second - Rate limiting mirrors Murmur's whole-second bucket leak, so irregular bursts are queued instead of dropped - CHANGELOG.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
6.3 KiB
Svelte
117 lines
6.3 KiB
Svelte
<script lang="ts">
|
|
import Icon from './Icon.svelte';
|
|
import Avatar from './Avatar.svelte';
|
|
import ChannelNode from './ChannelNode.svelte';
|
|
import { session } from '../lib/session.svelte.ts';
|
|
import { menu } 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 { store } from '../lib/settings.svelte.ts';
|
|
import type { Channel } from '../core/client.ts';
|
|
|
|
let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props();
|
|
|
|
const users = $derived(session.usersIn(channel.id));
|
|
const children = $derived(session.childrenOf(channel.id));
|
|
const expanded = $derived(session.isExpanded(channel.id));
|
|
const here = $derived(session.self?.channelId === channel.id);
|
|
const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id);
|
|
const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0);
|
|
const total = $derived(session.subtreeUserCount(channel.id));
|
|
|
|
// Single click previews, double click acts. The single action waits briefly so a
|
|
// double click does not also flash the preview.
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
function clickOrDouble(single: () => void, double: () => void) {
|
|
return {
|
|
onclick: () => { clearTimeout(timer); timer = setTimeout(single, 220); },
|
|
ondblclick: () => { clearTimeout(timer); double(); }
|
|
};
|
|
}
|
|
|
|
const channelClicks = clickOrDouble(
|
|
() => { session.showChannel(channel.id); ui.panelOpen = true; },
|
|
() => { session.joinChannel(channel.id); onnavigate(); }
|
|
);
|
|
</script>
|
|
|
|
<li>
|
|
<div class="row channel" class:here class:selected style:padding-left="{8 + depth * 14}px"
|
|
oncontextmenu={e => menu.show(e, channelMenu(channel))} role="presentation">
|
|
{#if children.length || users.length}
|
|
<button class="caret" class:collapsed={!expanded} onclick={() => session.toggleExpanded(channel.id)}
|
|
aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded}>
|
|
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
|
</button>
|
|
{:else}
|
|
<span class="caret"></span>
|
|
{/if}
|
|
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
|
|
{#if !channel.canEnter}<Icon name="lock" size={14} />{/if}
|
|
<span class="label">{channel.name || 'Root'}</span>
|
|
{#if !expanded && total > 0}<span class="count" title="{total} {total === 1 ? 'person' : 'people'} inside">{total}</span>{/if}
|
|
{#if unread}<span class="badge">{unread}</span>{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if users.length && expanded}
|
|
<ul class="users">
|
|
{#each users as u (u.session)}
|
|
{@const isSelf = u.session === session.self?.session}
|
|
{@const clicks = clickOrDouble(
|
|
() => { session.showUser(u.session); ui.panelOpen = true; },
|
|
() => { if (!isSelf) { session.selectView(`dm:${u.session}`); onnavigate(); } }
|
|
)}
|
|
<li>
|
|
<button class="row user" class:self={isSelf} style:padding-left="{30 + depth * 14}px"
|
|
title={isSelf ? 'You' : `${store.displayName(u)} (double-click to message)`} {...clicks}
|
|
oncontextmenu={e => menu.show(e, userMenu(u))}>
|
|
<Avatar name={u.name} size={22} speaking={voice.talking[u.session] || (isSelf && voice.transmitting)} />
|
|
<span class="label" class:talking={voice.talking[u.session] || (isSelf && voice.transmitting)}>{store.displayName(u)}</span>
|
|
{#if u.userId != null}<span class="reg" title="Registered">R</span>{/if}
|
|
{#if u.comment || u.commentHash}<span class="state" title="Has a description"><Icon name="message" size={12} /></span>{/if}
|
|
{#if u.prioritySpeaker}<span class="state" title="Priority speaker"><Icon name="channel" size={14} /></span>{/if}
|
|
{#if u.recording}<span class="rec" title="Recording">REC</span>{/if}
|
|
{#if u.deaf || u.selfDeaf}
|
|
<span class="state" class:admin={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="state" class:admin={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>
|
|
{/if}
|
|
{#if children.length && expanded}
|
|
<ul>
|
|
{#each children as c (c.id)}
|
|
<ChannelNode channel={c} depth={depth + 1} {onnavigate} />
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</li>
|
|
|
|
<style>
|
|
ul { list-style: none; margin: 0; padding: 0; }
|
|
li { margin: 0; }
|
|
.row { display: flex; align-items: center; gap: 4px; width: 100%; min-height: 30px; padding-right: 8px; color: var(--text-dim); }
|
|
.channel:hover, .user:hover { background: var(--bg-hover); color: var(--text); }
|
|
.channel.selected { background: var(--bg-active); color: var(--text); }
|
|
.channel.here .label { color: var(--text); font-weight: 600; }
|
|
.channel.here { box-shadow: inset 3px 0 0 var(--speaking); }
|
|
.caret { width: 14px; flex: none; display: inline-flex; justify-content: center; color: var(--text-faint); }
|
|
.caret.collapsed svg { transform: rotate(-90deg); }
|
|
.name { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 4px 0; text-align: left; color: inherit; user-select: none; }
|
|
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.count { font-size: 11px; color: var(--text-faint); border: 1px solid var(--line); padding: 0 5px; line-height: 16px; }
|
|
.badge { background: var(--danger); color: #fff; font-size: 11px; font-weight: 700; padding: 0 5px; line-height: 16px; }
|
|
.user { gap: 8px; text-align: left; font-size: 14px; user-select: none; }
|
|
.user.self .label { color: var(--text); }
|
|
.label.talking { color: var(--speaking); }
|
|
.reg { font-size: 10px; font-weight: 700; color: var(--accent-hover); border: 1px solid var(--accent); padding: 0 3px; line-height: 14px; }
|
|
.state { color: var(--text-faint); display: inline-flex; }
|
|
.state.admin { color: var(--danger); }
|
|
.rec { font-size: 10px; font-weight: 700; color: var(--danger); border: 1px solid var(--danger); padding: 0 3px; }
|
|
</style>
|