diff --git a/Client/tauri-client/src/components/MessageList.ts b/Client/tauri-client/src/components/MessageList.ts index 78c24a8b..0e6a93f2 100644 --- a/Client/tauri-client/src/components/MessageList.ts +++ b/Client/tauri-client/src/components/MessageList.ts @@ -379,7 +379,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo // doesn't cause jumps from estimate→measured height corrections. premeasureAll(); scrollToBottom(); - requestAnimationFrame(() => scrollToBottom()); + const initialScrollRaf = requestAnimationFrame(() => scrollToBottom()); + ac.signal.addEventListener("abort", () => cancelAnimationFrame(initialScrollRaf)); unsubscribers.push(messagesStore.subscribeSelector( (s) => s.messagesByChannel, diff --git a/Client/tauri-client/src/components/message-list/media.ts b/Client/tauri-client/src/components/message-list/media.ts index 2fa8424a..f046b6d6 100644 --- a/Client/tauri-client/src/components/message-list/media.ts +++ b/Client/tauri-client/src/components/message-list/media.ts @@ -66,8 +66,19 @@ export function extractYouTubeId(url: string): string | null { /** Cache for YouTube video titles to avoid re-fetching on every re-render. */ const ytTitleCache = new Map(); +/** Strict pattern for YouTube video IDs (alphanumeric, hyphens, underscores). */ +const YOUTUBE_ID_RE = /^[\w-]{1,20}$/; + /** Render a YouTube embed player with title header. */ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDivElement { + // Validate videoId to prevent injection into iframe src / img src. + if (!YOUTUBE_ID_RE.test(videoId)) { + const fallback = createElement("div", { class: "msg-embed" }); + const link = createElement("a", { href: originalUrl, target: "_blank", rel: "noopener noreferrer" }); + setText(link, originalUrl); + fallback.appendChild(link); + return fallback; + } const wrap = createElement("div", { class: "msg-embed msg-embed-youtube" }); // Header: channel name + video title @@ -85,10 +96,10 @@ export function renderYouTubeEmbed(videoId: string, originalUrl: string): HTMLDi setText(titleLink, cached); } else { setText(titleLink, "Loading..."); - const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; - fetch(oembedUrl) - .then((res) => (res.ok ? res.json() : null)) - .then((data: { title?: string } | null) => { + const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}&format=json`; + fetch(oembedUrl, { signal: AbortSignal.timeout(5000) }) + .then((res) => (res.ok ? (res.json() as Promise<{ title?: string } | null>) : null)) + .then((data) => { const title = data?.title ?? "YouTube Video"; ytTitleCache.set(videoId, title); setText(titleLink, title); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 75c4e677..5c6f6a08 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -1,11 +1,4 @@ -// ============================================================================= // LiveKit Session — lifecycle orchestrator for voice chat via LiveKit -// -// Replaces the old WebRTC-based voiceSession.ts. Manages LiveKit Room -// connection, mic publishing (with optional RNNoise pre-processing), -// remote track playback, and camera/screenshare. -// ============================================================================= - import { Room, RoomEvent, @@ -30,245 +23,17 @@ import type { NoiseSuppressor } from "@lib/noise-suppression"; const log = createLogger("livekitSession"); -// --------------------------------------------------------------------------- -// Module-level state (singleton) -// --------------------------------------------------------------------------- - -let room: Room | null = null; -let ws: WsClient | null = null; -let noiseSuppressor: NoiseSuppressor | null = null; -let onErrorCallback: ((message: string) => void) | null = null; -let currentChannelId: number | null = null; -/** Server host (e.g. "192.168.0.247:8443") for constructing LiveKit proxy URL. */ -let serverHost: string | null = null; - -// Speaking detection via audioLevel polling -let speakingPollInterval: ReturnType | null = null; -/** Cached sensitivity threshold (0.0-0.15). Updated by setVoiceSensitivity. */ -let speakingThreshold = ((100 - 50) / 100) * 0.15; // default: sensitivity 50 - -/** The raw mic stream acquired for RNNoise processing (must be stopped on cleanup). */ -let rawMicStream: MediaStream | null = null; - -// Remote audio playback -const audioElements = new Map(); -let audioContainer: HTMLDivElement | null = null; - -// Remote video callbacks -type RemoteVideoCallback = (userId: number, stream: MediaStream) => void; -type RemoteVideoRemovedCallback = (userId: number) => void; -let onRemoteVideoCallback: RemoteVideoCallback | null = null; -let onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +// --- Pure helpers (no instance state) --- /** Parse userId from LiveKit participant identity "user-{id}". Returns 0 if unparseable. */ -function parseUserId(identity: string): number { +export function parseUserId(identity: string): number { const match = identity.match(/^user-(\d+)$/); - if (match !== null && match[1] !== undefined) { - return parseInt(match[1], 10); - } + if (match !== null && match[1] !== undefined) return parseInt(match[1], 10); return 0; } -/** Get or create the hidden container for remote audio elements. */ -function getOrCreateAudioContainer(): HTMLDivElement { - if (audioContainer !== null) return audioContainer; - - const existing = document.getElementById("voice-audio-container"); - if (existing instanceof HTMLDivElement) { - audioContainer = existing; - return audioContainer; - } - - const div = document.createElement("div"); - div.id = "voice-audio-container"; - div.style.display = "none"; - document.body.appendChild(div); - audioContainer = div; - return audioContainer; -} - -/** Get saved per-user volume (0-200 range, default 100). */ -function getSavedUserVolume(userId: number): number { - return loadPref(`userVolume_${userId}`, 100); -} - -/** Clean up all remote audio elements. */ -function cleanupAudioElements(): void { - for (const el of audioElements.values()) { - el.srcObject = null; - el.remove(); - } - audioElements.clear(); -} - -// --------------------------------------------------------------------------- -// RNNoise integration -// --------------------------------------------------------------------------- - -/** Acquire mic, run through RNNoise, and publish the processed track to LiveKit. */ -async function publishWithNoiseSuppression(): Promise { - if (room === null) return; - - const savedDevice = loadPref("audioInputDevice", ""); - const constraints: MediaStreamConstraints = { - audio: { - deviceId: savedDevice ? { exact: savedDevice } : undefined, - echoCancellation: loadPref("echoCancellation", true), - noiseSuppression: loadPref("noiseSuppression", true), - autoGainControl: loadPref("autoGainControl", true), - }, - video: false, - }; - const rawStream = await navigator.mediaDevices.getUserMedia(constraints); - rawMicStream = rawStream; - noiseSuppressor = createNoiseSuppressor(); - const processedStream = await noiseSuppressor.process(rawStream); - const processedTrack = processedStream.getAudioTracks()[0]; - if (processedTrack) { - await room.localParticipant.publishTrack(processedTrack, { - source: Track.Source.Microphone, - }); - } -} - -// --------------------------------------------------------------------------- -// Room event handlers -// --------------------------------------------------------------------------- - -function handleTrackSubscribed( - track: RemoteTrack, - publication: RemoteTrackPublication, - participant: RemoteParticipant, -): void { - const userId = parseUserId(participant.identity); - - if (track.kind === Track.Kind.Audio) { - // Attach audio track to a hidden