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:
2026-10-01 00:01:30 +02:00
co-authored by Claude Opus 5.5
parent 6d29ee1485
commit aa679dddcc
29 changed files with 541 additions and 60 deletions
+8 -4
View File
@@ -1,6 +1,6 @@
import DOMPurify, { type Config } from 'dompurify';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube';
export type EmbedKind = 'image' | 'video' | 'audio' | 'youtube' | 'link';
export interface Embed { kind: EmbedKind; url: string; name: string; videoId?: string; start?: number }
// YouTube links: watch, youtu.be, shorts, embed, live, music, mobile
@@ -108,13 +108,17 @@ export function renderIncoming(html: string, trustedHosts: string[]): { html: st
}
let url: URL;
try { url = new URL(a.getAttribute('href')!); } catch { continue; }
if (!trustedHosts.includes(url.host.toLowerCase())) continue;
const trusted = trustedHosts.includes(url.host.toLowerCase());
const name = decodeURIComponent(url.pathname.split('/').pop() ?? '');
const kind = mediaKind(name);
const kind = trusted ? mediaKind(name) : null;
if (kind) embeds.push({ kind, url: url.href, name });
// Other web links get a preview card (not for the upload host's own files)
else if (!trusted && /^https?:$/.test(url.protocol) && embeds.filter(e => e.kind === 'link').length < 2 && !embeds.some(e => e.url === url.href)) {
embeds.push({ kind: 'link', url: url.href, name: a.textContent ?? '' });
}
}
// The inline thumbnail is only there for vanilla clients; we show the real media instead
if (embeds.some(e => e.kind !== 'youtube')) frag.querySelectorAll('img').forEach(img => img.remove());
if (embeds.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'audio')) frag.querySelectorAll('img').forEach(img => img.remove());
const div = document.createElement('div');
div.append(frag);
return { html: div.innerHTML.replace(/^(<br\s*\/?>)+|(<br\s*\/?>)+$/g, ''), embeds };