Add link previews, voice tiles and a menu volume slider; tidy the profile
- 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>
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
<span class="caret"></span>
|
||||
{/if}
|
||||
<button class="name" title="Click for details, double-click to join" {...channelClicks}>
|
||||
<Icon name={channel.canEnter ? 'channel' : 'lock'} size={16} />
|
||||
{#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}
|
||||
|
||||
+5
-1
@@ -2,9 +2,11 @@
|
||||
import Icon from './Icon.svelte';
|
||||
import ChatLog from './ChatLog.svelte';
|
||||
import Composer from './Composer.svelte';
|
||||
import VoiceStage from './VoiceStage.svelte';
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
|
||||
let { onmenu, onpanel, showMenu }: { onmenu: () => void; onpanel: () => void; showMenu: boolean } = $props();
|
||||
// showStage: voice tiles above the chat when the window is too narrow for the member list
|
||||
let { onmenu, onpanel, showMenu, showStage = false }: { onmenu: () => void; onpanel: () => void; showMenu: boolean; showStage?: boolean } = $props();
|
||||
|
||||
const isDm = $derived(session.view.startsWith('dm:'));
|
||||
const title = $derived(session.status === 'connected' ? session.viewTitle() : 'mumh5');
|
||||
@@ -24,6 +26,8 @@
|
||||
<button class="icon-btn" onclick={onpanel} title="Members"><Icon name="users" /></button>
|
||||
</header>
|
||||
|
||||
{#if showStage && session.status === 'connected'}<VoiceStage />{/if}
|
||||
|
||||
<ChatLog key={session.view}>
|
||||
{#snippet empty()}
|
||||
<div class="empty">
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
import { menu } from '../lib/menu.svelte.ts';
|
||||
|
||||
let el: HTMLDivElement | undefined = $state();
|
||||
// Live values of slider items while the menu is open
|
||||
let sliderValues = $state<Record<number, number>>({});
|
||||
$effect(() => { void menu.open; sliderValues = {}; });
|
||||
let pos = $state({ x: 0, y: 0 });
|
||||
|
||||
// Keep the menu on screen near the pointer
|
||||
@@ -21,6 +24,8 @@
|
||||
if (e.key === 'Escape') menu.close();
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
// Arrow keys adjust a focused slider instead of moving through the items
|
||||
if ((document.activeElement as HTMLInputElement | null)?.type === 'range') return;
|
||||
const buttons = [...el!.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];
|
||||
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
buttons[(i + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length]?.focus();
|
||||
@@ -36,6 +41,14 @@
|
||||
{#each menu.open.items as item, i (i)}
|
||||
{#if item.separator}
|
||||
<div class="sep" role="separator"></div>
|
||||
{:else if item.slider}
|
||||
{@const sl = item.slider}
|
||||
<div class="slider">
|
||||
<span class="slider-label"><span>{item.label}</span><span>{sl.format(sliderValues[i] ?? sl.value)}</span></span>
|
||||
<input type="range" min={sl.min} max={sl.max} step={sl.step} value={sl.value} aria-label={item.label}
|
||||
oninput={e => { const v = Number(e.currentTarget.value); sliderValues[i] = v; sl.oninput(v); }}
|
||||
ondblclick={() => { sliderValues[i] = 1; sl.oninput(1); }} />
|
||||
</div>
|
||||
{:else}
|
||||
<button role="menuitem" class:danger={item.danger} disabled={item.disabled}
|
||||
onclick={() => { menu.close(); item.action?.(); }}>
|
||||
@@ -61,4 +74,7 @@
|
||||
.box { width: 14px; height: 14px; border: 2px solid currentColor; }
|
||||
.box.on { background: currentColor; }
|
||||
.sep { height: 1px; margin: 4px 6px; background: var(--line); }
|
||||
.slider { padding: 6px 10px 8px; }
|
||||
.slider-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-dim); }
|
||||
.slider input { width: 100%; padding: 0; border: 0; background: transparent; accent-color: var(--accent); margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { linkPreview } from '../lib/preview.ts';
|
||||
import type { LinkPreviewData } from '../lib/native.ts';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
|
||||
let data = $state<LinkPreviewData | null>(null);
|
||||
let imageOk = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
let alive = true;
|
||||
linkPreview(url).then(d => { if (alive) data = d; });
|
||||
return () => { alive = false; };
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data}
|
||||
<article class="card" class:large={data.large && data.image && imageOk} style:--bar={data.color || 'var(--accent)'}>
|
||||
<div class="text">
|
||||
<span class="site">{data.site}</span>
|
||||
<a class="title" href={url} target="_blank" rel="noopener noreferrer">{data.title || data.url}</a>
|
||||
{#if data.description}<p class="desc">{data.description}</p>{/if}
|
||||
</div>
|
||||
{#if data.image && imageOk}
|
||||
<a class="thumb" href={url} target="_blank" rel="noopener noreferrer" tabindex="-1" aria-hidden="true">
|
||||
<img src={data.image} alt="" loading="lazy" referrerpolicy="no-referrer" onerror={() => (imageOk = false)} />
|
||||
</a>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card { display: flex; gap: 12px; max-width: min(520px, 100%); padding: 10px 12px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--bar); }
|
||||
.card.large { flex-direction: column; }
|
||||
.text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.site { font-size: 12px; color: var(--text-dim); }
|
||||
.title { font-weight: 600; color: var(--link); overflow-wrap: anywhere; }
|
||||
.desc { margin: 0; font-size: 13px; color: var(--text-dim); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.thumb { flex: none; width: 80px; height: 80px; }
|
||||
.large .thumb { width: 100%; height: auto; }
|
||||
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; background: var(--bg-0); }
|
||||
.large .thumb img { height: auto; max-height: 280px; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Avatar from './Avatar.svelte';
|
||||
import YouTubeEmbed from './YouTubeEmbed.svelte';
|
||||
import LinkPreview from './LinkPreview.svelte';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
import type { ChatEntry } from '../lib/session.svelte.ts';
|
||||
|
||||
@@ -33,7 +34,9 @@
|
||||
{/if}
|
||||
{#if entry.html}<div class="html">{@html entry.html}</div>{/if}
|
||||
{#each entry.embeds as m (m.url)}
|
||||
{#if m.kind === 'youtube'}
|
||||
{#if m.kind === 'link'}
|
||||
<div class="embed"><LinkPreview url={m.url} /></div>
|
||||
{:else if m.kind === 'youtube'}
|
||||
{#if store.settings.youtube !== 'off'}<div class="embed"><YouTubeEmbed videoId={m.videoId!} start={m.start} url={m.url} /></div>{/if}
|
||||
{:else}
|
||||
<div class="embed">
|
||||
|
||||
+10
-21
@@ -7,8 +7,8 @@
|
||||
import { session } from '../lib/session.svelte.ts';
|
||||
import { store } from '../lib/settings.svelte.ts';
|
||||
import { menu } from '../lib/menu.svelte.ts';
|
||||
import { userMenu, changeNamePrompt, nicknamePrompt } from '../lib/actions.ts';
|
||||
import { voice, userKey } from '../lib/audio/voice.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';
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
<Members {onnavigate} />
|
||||
{:else if channel && panel.kind === 'channel'}
|
||||
<header>
|
||||
<Icon name="channel" size={18} />
|
||||
<h2>{channel.name || 'Root'}</h2>
|
||||
<button class="icon-btn" onclick={() => session.closePanel()} aria-label="Close"><Icon name="x" size={18} /></button>
|
||||
</header>
|
||||
@@ -139,15 +138,15 @@
|
||||
<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>
|
||||
<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="badges">
|
||||
<span class="badge-reg" class:registered={user.userId != null}>{user.userId != null ? 'Registered' : 'Guest'}</span>
|
||||
{#if user.userId != null}<span class="badge-reg registered">Registered</span>{/if}
|
||||
{#if isSelf && user.userId == null}
|
||||
<button class="register" title="Reserve this name on the server, tied to your certificate" onclick={() => session.register(user.session)}>Register</button>
|
||||
{/if}
|
||||
<span class="dim small">in {session.channel(user.channelId)?.name || 'Root'}</span>
|
||||
</p>
|
||||
<p class="flags">
|
||||
{#if user.selfMute || user.mute}<span>{user.mute ? 'server muted' : 'muted'}</span>{/if}
|
||||
@@ -160,17 +159,6 @@
|
||||
|
||||
{#if isSelf}
|
||||
<div class="actions"><button class="btn" onclick={() => changeNamePrompt(user)}>Change name</button></div>
|
||||
{:else}
|
||||
<h4>Voice</h4>
|
||||
<label for="p-vol" class="vol">Volume for me: {Math.round((voice.settings.userVolumes[userKey(user)] ?? 1) * 100)}%</label>
|
||||
<input id="p-vol" type="range" min="0" max="3" step="0.05" value={voice.settings.userVolumes[userKey(user)] ?? 1}
|
||||
oninput={e => voice.setUserVolume(user, Number(e.currentTarget.value))} />
|
||||
<div class="actions">
|
||||
<button class="btn" class:danger={!!voice.settings.localMutes[userKey(user)]} onclick={() => voice.setLocalMute(user, !voice.settings.localMutes[userKey(user)])}>
|
||||
{voice.settings.localMutes[userKey(user)] ? 'Unmute for me' : 'Mute for me'}
|
||||
</button>
|
||||
<button class="btn" onclick={() => nicknamePrompt(user)}>Set nickname</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h4>Description</h4>
|
||||
@@ -223,16 +211,17 @@
|
||||
.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); }
|
||||
.badges { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; }
|
||||
.badges .small { margin: 0; }
|
||||
.badges { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 0; }
|
||||
.badge-reg { font-size: 11px; font-weight: 700; padding: 1px 6px; border: 1px solid var(--line); color: var(--text-dim); }
|
||||
.badge-reg.registered { border-color: var(--accent); background: var(--accent); color: var(--accent-text); }
|
||||
.register { font-size: 12px; color: var(--link); }
|
||||
.register:hover { text-decoration: underline; }
|
||||
.vol { margin-top: 0; }
|
||||
input[type=range] { padding: 0; border: 0; background: transparent; accent-color: var(--accent); }
|
||||
.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); }
|
||||
|
||||
@@ -75,6 +75,13 @@
|
||||
<label for="s-embed">Also show media inline from these hosts</label>
|
||||
<textarea id="s-embed" bind:value={embedHosts} onchange={saveFiles} rows="3" placeholder="one host per line, e.g. i.example.com"></textarea>
|
||||
<p class="help">Links from other hosts stay plain links, so opening a chat never contacts servers you did not choose.</p>
|
||||
<label for="s-lp">Link previews</label>
|
||||
<select id="s-lp" bind:value={s.linkPreviews} onchange={saveFiles}>
|
||||
<option value="host">Fetched by the upload host (keeps your IP private)</option>
|
||||
<option value="direct">Fetched by this computer (linked sites see your IP)</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
{#if s.linkPreviews === 'host' && !(s.uploadHost && s.uploadKey)}<p class="help">Needs an upload host and key above; until then no previews are shown.</p>{/if}
|
||||
<label for="s-yt">YouTube links</label>
|
||||
<select id="s-yt" bind:value={s.youtube} onchange={saveFiles}>
|
||||
<option value="click">Player card, loads from YouTube when clicked</option>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
let stats = $state<any>(null);
|
||||
let certs = $state<CertDetails[]>([]);
|
||||
let updated = $state(0);
|
||||
// Seconds since the last answer, so online and idle times tick between answers
|
||||
let now = $state(Date.now());
|
||||
|
||||
const client = session.client;
|
||||
const user = $derived(session.user(who));
|
||||
@@ -32,8 +34,10 @@
|
||||
}
|
||||
});
|
||||
client?.requestUserStats(who);
|
||||
const timer = setInterval(() => client?.requestUserStats(who, true), 5000);
|
||||
// Live: ask every second (UserStats is not rate limited by the server)
|
||||
const timer = setInterval(() => { now = Date.now(); client?.requestUserStats(who, true); }, 1000);
|
||||
onDestroy(() => { off?.(); clearInterval(timer); });
|
||||
const since = $derived(updated ? Math.max(0, Math.floor((now - updated) / 1000)) : 0);
|
||||
|
||||
function duration(secs?: number): string {
|
||||
if (secs == null) return 'unknown';
|
||||
@@ -73,8 +77,8 @@
|
||||
{:else}
|
||||
<h3>Connection</h3>
|
||||
<dl>
|
||||
<dt>Online for</dt><dd>{duration(stats.onlinesecs)}</dd>
|
||||
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs)}</dd>{/if}
|
||||
<dt>Online for</dt><dd>{duration(stats.onlinesecs != null ? stats.onlinesecs + since : undefined)}</dd>
|
||||
{#if stats.idlesecs != null}<dt>Idle for</dt><dd>{duration(stats.idlesecs + since)}</dd>{/if}
|
||||
<dt>Ping (TCP)</dt><dd>{ping(stats.tcp_ping_avg, stats.tcp_ping_var)}</dd>
|
||||
<dt>Ping (UDP)</dt><dd>{stats.udp_packets ? ping(stats.udp_ping_avg, stats.udp_ping_var) : 'not using UDP (voice over TCP)'}</dd>
|
||||
<dt>Packets</dt><dd>{stats.tcp_packets ?? 0} TCP, {stats.udp_packets ?? 0} UDP</dd>
|
||||
@@ -124,7 +128,6 @@
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if updated}<span class="dim small upd">Updates every 5 seconds</span>{/if}
|
||||
<button class="btn primary" onclick={onclose}>Close</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -144,6 +147,5 @@
|
||||
.small { font-size: 12px; margin-top: 6px; }
|
||||
.note { margin-top: 16px; }
|
||||
.cert { margin-top: 10px; }
|
||||
.upd { margin-right: auto; align-self: center; }
|
||||
@media (max-width: 500px) { dl { grid-template-columns: 1fr; } dt { margin-top: 6px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import Avatar from './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';
|
||||
|
||||
// People in your voice channel as tiles, for windows too narrow for the member list
|
||||
const self = $derived(session.self);
|
||||
const channel = $derived(self ? session.channel(self.channelId) : undefined);
|
||||
const people = $derived(self ? session.usersIn(self.channelId) : []);
|
||||
|
||||
let collapsed = $state(load());
|
||||
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 */ }
|
||||
}
|
||||
|
||||
const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting);
|
||||
</script>
|
||||
|
||||
{#if self}
|
||||
<section class="stage" aria-label="Voice channel">
|
||||
<header>
|
||||
<button class="title" onclick={toggle} aria-expanded={!collapsed}>
|
||||
<svg class="caret" class:collapsed width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3l3 4 3-4" fill="none" stroke="currentColor" stroke-width="1.6" /></svg>
|
||||
<span class="name">{channel?.name || 'Root'}</span>
|
||||
<span class="count">{people.length}</span>
|
||||
{#if collapsed}
|
||||
<span class="mini" aria-hidden="true">
|
||||
{#each people.slice(0, 8) as u (u.session)}<Avatar name={u.name} size={18} speaking={talking(u.session)} />{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="icon-btn" class:on={self.selfMute} aria-label={self.selfMute ? 'Unmute' : 'Mute'} title={self.selfMute ? 'Unmute' : 'Mute'}
|
||||
onclick={() => session.setSelfMute(!self.selfMute)}>
|
||||
<Icon name={self.selfMute ? 'mic-off' : 'mic'} size={18} />
|
||||
</button>
|
||||
<button class="icon-btn" class:on={self.selfDeaf} aria-label={self.selfDeaf ? 'Undeafen' : 'Deafen'} title={self.selfDeaf ? 'Undeafen' : 'Deafen'}
|
||||
onclick={() => session.setSelfDeaf(!self.selfDeaf)}>
|
||||
<Icon name={self.selfDeaf ? 'headphones-off' : 'headphones'} size={18} />
|
||||
</button>
|
||||
</header>
|
||||
{#if !collapsed}
|
||||
<ul class="tiles">
|
||||
{#each people as u (u.session)}
|
||||
<li>
|
||||
<button class="tile" class:talking={talking(u.session)} class:self={u.session === self.session}
|
||||
onclick={() => { session.showUser(u.session); ui.panelOpen = true; }}
|
||||
oncontextmenu={e => menu.show(e, userMenu(u))} title={store.displayName(u)}>
|
||||
<Avatar name={u.name} size={44} speaking={talking(u.session)} />
|
||||
<span class="who">{store.displayName(u)}</span>
|
||||
{#if u.deaf || u.selfDeaf}
|
||||
<span class="badge" class:admin={u.deaf}><Icon name="headphones-off" size={13} /></span>
|
||||
{:else if u.mute || u.selfMute || u.suppress}
|
||||
<span class="badge" class:admin={u.mute || u.suppress}><Icon name="mic-off" size={13} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stage { flex: none; border-bottom: 1px solid var(--line); background: var(--bg-1); }
|
||||
header { display: flex; align-items: center; gap: 2px; padding: 4px 8px 4px 4px; }
|
||||
.title { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 6px 8px; text-align: left; }
|
||||
.title:hover { background: var(--bg-hover); }
|
||||
.caret { flex: none; color: var(--text-faint); }
|
||||
.caret.collapsed { transform: rotate(-90deg); }
|
||||
.name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.count { font-size: 12px; color: var(--text-faint); }
|
||||
.mini { display: flex; gap: 3px; min-width: 0; overflow: hidden; margin-left: 4px; }
|
||||
.tiles { list-style: none; margin: 0; padding: 0 10px 10px; display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 6px;
|
||||
max-height: 40vh; overflow-y: auto; }
|
||||
.tile { position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px 8px;
|
||||
background: var(--bg-2); border: 1px solid var(--line); transition: border-color 80ms, box-shadow 80ms; }
|
||||
.tile:hover { background: var(--bg-hover); }
|
||||
.tile.talking { border-color: var(--speaking); box-shadow: inset 0 0 0 1px var(--speaking); }
|
||||
.who { max-width: 100%; font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile.self .who { color: var(--text); font-weight: 600; }
|
||||
.badge { position: absolute; top: 4px; right: 4px; display: inline-flex; color: var(--text-faint); }
|
||||
.badge.admin { color: var(--danger); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user