This commit is contained in:
Arjun Patel
2026-06-01 14:15:22 -07:00
parent 2d9b4805e0
commit 580703fdf6
21 changed files with 302 additions and 220 deletions
@@ -21,42 +21,45 @@ export function useAudioSource(
>(new WeakMap()); >(new WeakMap());
useEffect(() => { useEffect(() => {
if (!source) { // Build the source node (creating an AudioContext as needed) plus optional
setAudioSource(null); // teardown for contexts we own; the branches converge on one setState.
return; let result: AudioSource | null = null;
} let cleanup: (() => void) | undefined;
if (source instanceof MediaStream) { if (source instanceof MediaStream) {
const ctx = new AudioContext(); const ctx = new AudioContext();
ctx.resume(); ctx.resume();
const sourceNode = ctx.createMediaStreamSource(source); const sourceNode = ctx.createMediaStreamSource(source);
setAudioSource({ sourceNode, ctx }); result = { sourceNode, ctx };
cleanup = () => ctx.close();
return () => { } else if (source) {
ctx.close(); // HTMLAudioElement — createMediaElementSource can only be called once per
}; // element, so reuse a cached context/node when we have one.
const cached = elementSourceCache.current.get(source);
if (cached) {
cached.ctx.resume();
result = cached;
} else {
const ctx = new AudioContext();
ctx.resume();
const sourceNode = ctx.createMediaElementSource(source);
// Connect element source to destination so audio is still audible
sourceNode.connect(ctx.destination);
elementSourceCache.current.set(source, { sourceNode, ctx });
result = { sourceNode, ctx };
cleanup = () => {
ctx.close();
elementSourceCache.current.delete(source);
};
}
} }
// HTMLAudioElement — createMediaElementSource can only be called once per element // Publishing an imperatively-created Web Audio node — external-resource
const cached = elementSourceCache.current.get(source); // sync, not a re-render cascade.
if (cached) { // eslint-disable-next-line react-hooks/set-state-in-effect
cached.ctx.resume(); setAudioSource(result);
setAudioSource(cached);
return;
}
const ctx = new AudioContext(); return cleanup;
ctx.resume();
const sourceNode = ctx.createMediaElementSource(source);
// Connect element source to destination so audio is still audible
sourceNode.connect(ctx.destination);
elementSourceCache.current.set(source, { sourceNode, ctx });
setAudioSource({ sourceNode, ctx });
return () => {
ctx.close();
elementSourceCache.current.delete(source);
};
}, [source]); }, [source]);
return audioSource; return audioSource;
@@ -23,16 +23,13 @@ export function ScreenSourcePicker({
getSources().then((result) => { getSources().then((result) => {
setSources(result); setSources(result);
setLoading(false); setLoading(false);
// Auto-select if there's only one source.
if (result.length === 1) {
setSelectedId(result[0].id);
}
}); });
}, [getSources]); }, [getSources]);
// Auto-select if there's only one source
useEffect(() => {
if (!loading && sources.length === 1) {
setSelectedId(sources[0].id);
}
}, [loading, sources]);
const screens = sources.filter((s) => s.id.startsWith("screen:")); const screens = sources.filter((s) => s.id.startsWith("screen:"));
const windows = sources.filter((s) => s.id.startsWith("window:")); const windows = sources.filter((s) => s.id.startsWith("window:"));
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect } from "react";
import { Dialog as DialogPrimitive } from "radix-ui"; import { Dialog as DialogPrimitive } from "radix-ui";
import { import {
ChevronLeft, ChevronLeft,
@@ -10,6 +10,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useDownloadUrl } from "@/hooks/use-download-url"; import { useDownloadUrl } from "@/hooks/use-download-url";
import { useObjectUrl } from "@/hooks/use-object-url";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { platform } from "@/lib/platform"; import { platform } from "@/lib/platform";
@@ -71,17 +72,10 @@ export function AttachmentLightbox({
const { data: remoteUrl, isLoading: isRemoteLoading } = const { data: remoteUrl, isLoading: isRemoteLoading } =
useDownloadUrl(remoteObjectId); useDownloadUrl(remoteObjectId);
// Local items get a fresh blob URL per item, revoked on change/close. // Local items resolve to a blob URL; remote items use the signed-URL cache.
const [localUrl, setLocalUrl] = useState<string | null>(null); const localFile =
useEffect(() => { current?.source.kind === "local" ? current.source.file : null;
if (current?.source.kind !== "local") { const localUrl = useObjectUrl(localFile);
setLocalUrl(null);
return;
}
const url = URL.createObjectURL(current.source.file);
setLocalUrl(url);
return () => URL.revokeObjectURL(url);
}, [current?.id, current?.source.kind]);
const url = const url =
current?.source.kind === "remote" current?.source.kind === "remote"
@@ -90,18 +84,21 @@ export function AttachmentLightbox({
const canDownload = current?.source.kind === "remote" && !!url; const canDownload = current?.source.kind === "remote" && !!url;
const goTo = (delta: number) => { const goTo = useCallback(
if (openIndex === null || items.length === 0) return; (delta: number) => {
const next = (openIndex + delta + items.length) % items.length; if (openIndex === null || items.length === 0) return;
onOpenChange(next); const next = (openIndex + delta + items.length) % items.length;
}; onOpenChange(next);
},
[openIndex, items.length, onOpenChange],
);
const handleDownload = () => { const handleDownload = useCallback(() => {
if (!current || !url || current.source.kind !== "remote") return; if (!current || !url || current.source.kind !== "remote") return;
platform.attachment.download(url, current.filename); platform.attachment.download(url, current.filename);
}; }, [current, url]);
const handleRemove = () => { const handleRemove = useCallback(() => {
if (!current || !onRemove) return; if (!current || !onRemove) return;
const wasLast = items.length <= 1; const wasLast = items.length <= 1;
const wasAtEnd = openIndex === items.length - 1; const wasAtEnd = openIndex === items.length - 1;
@@ -112,7 +109,7 @@ export function AttachmentLightbox({
onOpenChange(items.length - 2); onOpenChange(items.length - 2);
} }
// Otherwise openIndex stays — the next item shifts into its place. // Otherwise openIndex stays — the next item shifts into its place.
}; }, [current, onRemove, items.length, openIndex, onOpenChange]);
useSuspendPlayback(isOpen, "attachment-lightbox"); useSuspendPlayback(isOpen, "attachment-lightbox");
@@ -154,7 +151,7 @@ export function AttachmentLightbox({
}; };
window.addEventListener("keydown", handle, true); window.addEventListener("keydown", handle, true);
return () => window.removeEventListener("keydown", handle, true); return () => window.removeEventListener("keydown", handle, true);
}, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]); }, [isOpen, onOpenChange, onRemove, hasMultiple, canDownload, goTo, handleDownload, handleRemove]);
const isImage = current?.mimeType.startsWith("image/"); const isImage = current?.mimeType.startsWith("image/");
const isVideo = current?.mimeType.startsWith("video/"); const isVideo = current?.mimeType.startsWith("video/");
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
@@ -80,15 +80,17 @@ export function ComposeOverlay({
const invalidateUsage = useInvalidateNetworkUsage(); const invalidateUsage = useInvalidateNetworkUsage();
const quotaExhausted = isUsageExhausted(usage); const quotaExhausted = isUsageExhausted(usage);
// Refs for synchronous reads in keyboard handlers // Latest props/state for synchronous reads in keyboard handlers.
const stepRef = useRef(step); const stepRef = useRef(step);
const recordStartRef = useRef(0); const recordStartRef = useRef(0);
const disabledRef = useRef(disabled); const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const quotaExhaustedRef = useRef(quotaExhausted); const quotaExhaustedRef = useRef(quotaExhausted);
quotaExhaustedRef.current = quotaExhausted;
const recordingSourceRef = useRef(recordingSource); const recordingSourceRef = useRef(recordingSource);
recordingSourceRef.current = recordingSource; useEffect(() => {
disabledRef.current = disabled;
quotaExhaustedRef.current = quotaExhausted;
recordingSourceRef.current = recordingSource;
}, [disabled, quotaExhausted, recordingSource]);
const setStepSync = useCallback((next: ComposeStep) => { const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next; stepRef.current = next;
@@ -361,8 +363,8 @@ export function ComposeOverlay({
return false; return false;
}, [cancel]); }, [cancel]);
// Reply mode: create particle directly under targetPath // Reply mode: create particle directly under targetPath.
const onSubmitReply = useEffectEvent(async () => { const onSubmitReply = useCallback(async () => {
if (!targetPath || !userId || stepRef.current === "submitting") return; if (!targetPath || !userId || stepRef.current === "submitting") return;
setStepSync("submitting"); setStepSync("submitting");
try { try {
@@ -371,7 +373,7 @@ export function ComposeOverlay({
} catch (err) { } catch (err) {
if (!handleQuotaError(err)) throw err; if (!handleQuotaError(err)) throw err;
} }
}); }, [targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError]);
// New stream mode: create stream + first child // New stream mode: create stream + first child
const handleStreamSubmit = useCallback( const handleStreamSubmit = useCallback(
@@ -397,7 +399,7 @@ export function ComposeOverlay({
if (!handleQuotaError(err)) throw err; if (!handleQuotaError(err)) throw err;
} }
}, },
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError], [networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync],
); );
// --- Compose intent handlers --- // --- Compose intent handlers ---
@@ -468,20 +470,25 @@ export function ComposeOverlay({
// executes the matching handler and clears the intent. Keyboard handlers // executes the matching handler and clears the intent. Keyboard handlers
// call the same handlers directly without a store round-trip. // call the same handlers directly without a store round-trip.
const intent = useComposeIntentStore((s) => s.intent);
const clearIntent = useComposeIntentStore((s) => s.clear); const clearIntent = useComposeIntentStore((s) => s.clear);
// Consume fire-and-forget intents from the external store. Reacting in the
// store subscription (not an effect body) keeps these state-updating handlers
// off the render path and avoids an extra dispatch→render bounce.
useEffect(() => { useEffect(() => {
if (!intent) return; return useComposeIntentStore.subscribe((state, prev) => {
switch (intent.kind) { const intent = state.intent;
case "record": handleRecordIntent(); break; if (!intent || intent === prev.intent) return;
case "text": handleTextIntent(); break; switch (intent.kind) {
case "stop": handleStopIntent(); break; case "record": handleRecordIntent(); break;
case "cancel": handleCancelIntent(); break; case "text": handleTextIntent(); break;
case "send": handleSendIntent(); break; case "stop": handleStopIntent(); break;
} case "cancel": handleCancelIntent(); break;
clearIntent(); case "send": handleSendIntent(); break;
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]); }
clearIntent();
});
}, [handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
// --- Keyboard handling --- // --- Keyboard handling ---
@@ -3,6 +3,7 @@ import { Paperclip } from "lucide-react";
import type { RecordingMode } from "@/hooks/use-recording-mode"; import type { RecordingMode } from "@/hooks/use-recording-mode";
import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source"; import { useAudioSource } from "@/components/audio/use-audio-source";
import { useObjectUrl } from "@/hooks/use-object-url";
import { AttachmentStrip } from "@/features/compose/attachment-strip"; import { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip"; import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -65,23 +66,11 @@ function ReviewPlayback({
mirror?: boolean; mirror?: boolean;
objectFit?: "cover" | "contain"; objectFit?: "cover" | "contain";
}) { }) {
const urlRef = useRef<string | null>(null); const objectUrl = useObjectUrl(blob);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const audioElRef = useRef<HTMLAudioElement | null>(null); const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null); const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(isVideo ? null : audioEl); const audioSource = useAudioSource(isVideo ? null : audioEl);
useEffect(() => {
const url = URL.createObjectURL(blob);
urlRef.current = url;
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
urlRef.current = null;
};
}, [blob]);
if (!objectUrl) return null; if (!objectUrl) return null;
if (isVideo) { if (isVideo) {
@@ -33,22 +33,19 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
onEnded, onEnded,
onProgress, onProgress,
}, ref) { }, ref) {
// Prefer the worker-produced iOS-playable variant when present so desktop // Prefer the worker-produced iOS-playable variant when present so desktop and
// and mobile read the same canonical asset. Falls back to the original. // mobile read the same canonical asset, falling back to the original. Pinned
// Pin the choice for the lifetime of this particle: if a transcoded variant // on mount (the parent keys this component by particle.id, so a new particle
// arrives via Firestore mid-playback, swapping the <video> src would restart // remounts and re-picks): if a transcoded variant arrives via Firestore for
// playback from 0. Keep whatever we picked first; the original plays fine in // the same particle, swapping the <video> src would restart playback from 0.
// Electron, and the transcoded variant will be picked up on the next view. const [pickedSource] = useState(() => ({
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null); objectId:
if (pickedSourceRef.current?.id !== particle.id) { particle.properties.transcoded_object_id ?? particle.properties.object_id,
pickedSourceRef.current = { mime:
id: particle.id, particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id, }));
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type, const activeObjectId = pickedSource.objectId;
}; const activeMime = pickedSource.mime;
}
const activeObjectId = pickedSourceRef.current.objectId;
const activeMime = pickedSourceRef.current.mime;
const { data: url, error } = useDownloadUrl(activeObjectId); const { data: url, error } = useDownloadUrl(activeObjectId);
const { attachments } = useParticleAttachments(streamPath, particle.id); const { attachments } = useParticleAttachments(streamPath, particle.id);
@@ -1,4 +1,4 @@
import { useMemo, useRef, useEffect, useCallback, memo } from "react"; import { useMemo, useRef, useEffect, useCallback, memo, createElement } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Radio, Radio,
@@ -186,7 +186,9 @@ const StreamRow = memo(function StreamRow({
? getMessagePreview(latestChild) ? getMessagePreview(latestChild)
: particle.properties.name; : particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio; // Rendered via createElement below: a call-result used directly as a JSX tag
// is flagged as a dynamically-created component.
const typeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
const videoThumbObjectId = const videoThumbObjectId =
latestChild && latestChild &&
@@ -254,12 +256,12 @@ const StreamRow = memo(function StreamRow({
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<TypeIcon {createElement(typeIcon, {
className={cn( className: cn(
"size-3.5 shrink-0", "size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground", isUnseen ? "text-foreground" : "text-muted-foreground",
)} ),
/> })}
<Small <Small
className={cn( className={cn(
"truncate", "truncate",
@@ -34,7 +34,10 @@ export function StreamMembersOverlay({
const network = useNetwork(networkId); const network = useNetwork(networkId);
const humans = network?.humans ?? []; const humans = network?.humans ?? [];
const creatorId = streamParticle.created_by_human_id; const creatorId = streamParticle.created_by_human_id;
const visibility = parseVisibleTo(streamParticle.visible_to, networkId); const visibility = useMemo(
() => parseVisibleTo(streamParticle.visible_to, networkId),
[streamParticle.visible_to, networkId],
);
const docPath = useMemo( const docPath = useMemo(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])), () => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
@@ -51,24 +51,26 @@ function useExitCountdown(
disabled: boolean, disabled: boolean,
onExit: () => void, onExit: () => void,
) { ) {
const [remainingMs, setRemainingMs] = useState<number | null>(null); const [remainingMs, setRemainingMs] = useState<number | null>(
status === "ended" ? EXIT_DELAY_MS : null,
);
const [prevStatus, setPrevStatus] = useState(status);
const handleExit = useEffectEvent(() => { const handleExit = useEffectEvent(() => {
onExit(); onExit();
}); });
// Start/cancel countdown based on playback status // Start the countdown when playback ends; cancel it otherwise.
useEffect(() => { if (status !== prevStatus) {
if (status === "ended") { setPrevStatus(status);
setRemainingMs(EXIT_DELAY_MS); setRemainingMs(status === "ended" ? EXIT_DELAY_MS : null);
} else { }
setRemainingMs(null);
} const isCountingDown = remainingMs !== null && remainingMs > 0;
}, [status]);
// Tick the countdown down (pauses when compose is active) // Tick the countdown down (pauses when compose is active)
useEffect(() => { useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || disabled) return; if (!isCountingDown || disabled) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setRemainingMs((prev) => { setRemainingMs((prev) => {
@@ -79,7 +81,7 @@ function useExitCountdown(
}, EXIT_TICK_MS); }, EXIT_TICK_MS);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, disabled]); }, [isCountingDown, disabled]);
// Navigate once countdown hits zero // Navigate once countdown hits zero
useEffect(() => { useEffect(() => {
@@ -197,12 +199,13 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const reactions = getReactions(currentParticle); const reactions = getReactions(currentParticle);
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions); toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
}, [authedUser, currentParticle]); }, [authedUser, currentParticle, networkId, streamParticle.id]);
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle"); const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const paused = usePlaybackPauseStore(selectIsPaused); const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
const [showKeybindings, setShowKeybindings] = useState(false); const [showKeybindings, setShowKeybindings] = useState(false);
const [textReactionOpen, setTextReactionOpen] = useState(false); const [textReactionOpen, setTextReactionOpen] = useState(false);
@@ -286,10 +289,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
handleExitNavigate, handleExitNavigate,
); );
// Reset progress when particle changes // Reset progress when the particle changes.
useEffect(() => { if (currentParticle?.id !== prevParticleId) {
setPrevParticleId(currentParticle?.id);
setProgress(0); setProgress(0);
}, [currentParticle?.id]); }
const handleParticleCreated = useCallback((particleId: string) => { const handleParticleCreated = useCallback((particleId: string) => {
if (currentIndex === -1) return; if (currentIndex === -1) return;
@@ -14,13 +14,19 @@ interface TextReactionInputProps {
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) { export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [prevOpen, setPrevOpen] = useState(open);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useSuspendPlayback(open, "text-reaction"); useSuspendPlayback(open, "text-reaction");
// Clear the input each time the popup opens.
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setValue("");
}
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setValue("");
const id = requestAnimationFrame(() => inputRef.current?.focus()); const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [open]); }, [open]);
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react"; import { useMemo, useState } from "react";
import type { Transcript } from "@/api/types"; import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number]; type Sentence = Transcript["paragraphs"][number]["sentences"][number];
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
const activeWord = const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null; activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
// Remember the last spoken word so highlights hold during pauses // Remember the last spoken word so highlights hold during pauses.
const lastSpokenWordRef = useRef<Word | null>(null); const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
if (activeWord) { if (activeWord && activeWord !== lastSpokenWord) {
lastSpokenWordRef.current = activeWord; setLastSpokenWord(activeWord);
} }
const highlightWord = activeWord ?? lastSpokenWordRef.current; const highlightWord = activeWord ?? lastSpokenWord;
const lastChunkRef = useRef<Word[] | null>(null); // The chunk currently being spoken (null during a pause or if not found).
const spokenChunk = useMemo(() => {
// Find which chunk contains the active word, holding the last one during pauses if (!activeWord) return null;
const activeChunk = useMemo(() => { return (
if (activeWord) { chunks.find((chunk) =>
for (const chunk of chunks) { chunk.some(
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) { (w) => w.start === activeWord.start && w.end === activeWord.end,
lastChunkRef.current = chunk; ),
return chunk; ) ?? null
} );
}
}
// No active word (speaker pausing) — hold the last chunk
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
return lastChunkRef.current;
}
// Sentence changed, last chunk no longer valid — use first chunk
const fallback = chunks[0] ?? null;
lastChunkRef.current = fallback;
return fallback;
}, [chunks, activeWord]); }, [chunks, activeWord]);
// Resolve which chunk to display: the spoken one, else hold the last one while
// it's still part of the current sentence, else fall back to the first chunk.
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
let activeChunk: Word[] | null;
if (spokenChunk) {
activeChunk = spokenChunk;
} else if (lastChunk && chunks.includes(lastChunk)) {
activeChunk = lastChunk;
} else {
activeChunk = chunks[0] ?? null;
}
if (activeChunk !== lastChunk) {
setLastChunk(activeChunk);
}
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null; if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return ( return (
@@ -34,12 +34,20 @@ function usePreviewStream(
): { stream: MediaStream | null; error: string | null } { ): { stream: MediaStream | null; error: string | null } {
const [stream, setStream] = useState<MediaStream | null>(null); const [stream, setStream] = useState<MediaStream | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [prevEnabled, setPrevEnabled] = useState(enabled);
useEffect(() => { // Clear the preview when disabled; the effect below only manages the
// getUserMedia subscription.
if (enabled !== prevEnabled) {
setPrevEnabled(enabled);
if (!enabled) { if (!enabled) {
setStream(null); setStream(null);
return; setError(null);
} }
}
useEffect(() => {
if (!enabled) return;
let cancelled = false; let cancelled = false;
let active: MediaStream | null = null; let active: MediaStream | null = null;
+4 -5
View File
@@ -23,11 +23,7 @@ export function useChannel(channelId: string | null): UseChannelResult {
const [messages, setMessages] = useState<ChannelMessage[]>([]); const [messages, setMessages] = useState<ChannelMessage[]>([]);
useEffect(() => { useEffect(() => {
if (!client || !channelId) { if (!client || !channelId) return;
setPresence([]);
setMessages([]);
return;
}
client.subscribe(channelId); client.subscribe(channelId);
@@ -66,6 +62,9 @@ export function useChannel(channelId: string | null): UseChannelResult {
client.off(channelId, "leave", onLeave); client.off(channelId, "leave", onLeave);
client.off(channelId, "message", onMessage); client.off(channelId, "message", onMessage);
client.unsubscribe(channelId); client.unsubscribe(channelId);
// Clear on teardown so a new channel doesn't briefly show stale data.
setPresence([]);
setMessages([]);
}; };
}, [client, channelId]); }, [client, channelId]);
+5 -2
View File
@@ -10,9 +10,12 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0); const dragCountRef = useRef(0);
// Stable ref for the callback to avoid re-registering effects // Latest callback in a ref, so the effects below don't re-register when the
// caller passes a new function each render.
const onFilesRef = useRef(onFilesSelected); const onFilesRef = useRef(onFilesSelected);
onFilesRef.current = onFilesSelected; useEffect(() => {
onFilesRef.current = onFilesSelected;
}, [onFilesSelected]);
// Hidden file input element // Hidden file input element
useEffect(() => { useEffect(() => {
+17 -15
View File
@@ -25,21 +25,23 @@ export function useMediaDevices(): UseMediaDevicesResult {
useState<PermissionState>("unknown"); useState<PermissionState>("unknown");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(() => {
try { return navigator.mediaDevices
const list = await navigator.mediaDevices.enumerateDevices(); .enumerateDevices()
setDevices(list); .then((list) => {
// If at least one input device has a non-empty label, permission setDevices(list);
// has been granted at some point for that device kind. // If at least one input device has a non-empty label, permission
const hasLabels = list.some( // has been granted at some point for that device kind.
(d) => const hasLabels = list.some(
(d.kind === "audioinput" || d.kind === "videoinput") && (d) =>
d.label.length > 0, (d.kind === "audioinput" || d.kind === "videoinput") &&
); d.label.length > 0,
if (hasLabels) setPermissionState("granted"); );
} catch (err) { if (hasLabels) setPermissionState("granted");
setError(err instanceof Error ? err.message : "Failed to list devices"); })
} .catch((err) => {
setError(err instanceof Error ? err.message : "Failed to list devices");
});
}, []); }, []);
const requestLabels = useCallback(async () => { const requestLabels = useCallback(async () => {
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
/**
* Creates an object URL for a Blob/File and revokes it when the source changes
* or the component unmounts. Returns null when given null.
*
* `createObjectURL` is an imperative side effect that must run inside an effect,
* so publishing the resulting URL to state here is genuine external-resource
* synchronization rather than a render cascade hence the single, contained
* lint suppression below.
*/
export function useObjectUrl(source: Blob | null): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!source) return;
const objectUrl = URL.createObjectURL(source);
// Intended external-resource publish (see hook doc), not a render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
setUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
setUrl(null); // in cleanup, not the effect body — so no suppression needed
};
}, [source]);
return source ? url : null;
}
+39 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { import {
subscribeToParticle, subscribeToParticle,
subscribeToParticleChildren, subscribeToParticleChildren,
@@ -27,10 +27,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
useEffect(() => { useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path); const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle( const unsubscribe = subscribeToParticle(
docPath, docPath,
@@ -44,7 +40,13 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
}, },
); );
return unsubscribe; return () => {
unsubscribe();
// Reset on teardown so a new path doesn't flash the previous particle.
setIsLoading(true);
setError(null);
setParticle(null);
};
}, [path]); }, [path]);
return { particle, isLoading, error }; return { particle, isLoading, error };
@@ -83,16 +85,17 @@ export function useLiveParticleChildren(
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
// Keep the latest add/remove callbacks in refs so changing them doesn't force
// the subscription to re-attach — they're notifications, not query params.
const onAddedRef = useRef(onAdded);
const onRemovedRef = useRef(onRemoved);
useEffect(() => { useEffect(() => {
if (!path) { onAddedRef.current = onAdded;
setChildren([]); onRemovedRef.current = onRemoved;
setIsLoading(false); }, [onAdded, onRemoved]);
return;
}
setIsLoading(true); useEffect(() => {
setError(null); if (!path) return;
setChildren([]);
const collectionPath = toFirestoreChildrenPath(path); const collectionPath = toFirestoreChildrenPath(path);
@@ -111,15 +114,27 @@ export function useLiveParticleChildren(
visibilityScopes, visibilityScopes,
orderByField, orderByField,
orderDirection, orderDirection,
onAdded, onAdded: (child: Particle) => onAddedRef.current?.(child),
onRemoved, onRemoved: (child: Particle, updatedChildren: Particle[]) =>
onRemovedRef.current?.(child, updatedChildren),
whereFilter, whereFilter,
limit, limit,
} }
); );
return unsubscribe; return () => {
}, [path, whereFilter, limit]); unsubscribe();
// Reset on teardown so a new path doesn't flash the previous children.
setChildren([]);
setError(null);
setIsLoading(true);
};
}, [path, whereFilter, limit, orderByField, orderDirection, visibilityScopes]);
// No path: nothing to load, so report an empty non-loading state.
if (!path) {
return { children: [], isLoading: false, error: null };
}
return { children, isLoading, error }; return { children, isLoading, error };
} }
@@ -134,9 +149,6 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild( const unsubscribe = subscribeToLatestChild(
toFirestoreChildrenPath(path), toFirestoreChildrenPath(path),
(data) => { (data) => {
@@ -148,7 +160,12 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
}, },
); );
return unsubscribe; return () => {
unsubscribe();
// Reset on teardown so a new path doesn't flash the previous child.
setIsLoading(true);
setLatestChild(null);
};
}, [path]); }, [path]);
return { latestChild, isLoading }; return { latestChild, isLoading };
+14 -10
View File
@@ -12,19 +12,23 @@ export function useStreamKeyboardNav({
enabled, enabled,
onNavigate, onNavigate,
}: UseStreamKeyboardNavOptions) { }: UseStreamKeyboardNavOptions) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const [selectedIndex, setSelectedIndex] = useState<number | null>(
streams.length > 0 ? 0 : null,
);
const [prevStreamCount, setPrevStreamCount] = useState(streams.length);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Initialize selection when streams first load; clear if streams become empty. // Select the first stream once they load and clear when empty — but not on
// Do NOT reset on every Firestore update — that would scroll the list to the top. // every Firestore update, which would scroll the list back to the top.
useEffect(() => { if (streams.length !== prevStreamCount) {
setSelectedIndex((prev) => { setPrevStreamCount(streams.length);
if (streams.length === 0) return null; if (streams.length === 0) {
if (prev === null) return 0; setSelectedIndex(null);
return prev; } else if (selectedIndex === null) {
}); setSelectedIndex(0);
}, [streams.length]); }
}
useEffect(() => { useEffect(() => {
if (!enabled || streams.length === 0) return; if (!enabled || streams.length === 0) return;
+7 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { where, type QueryFieldFilterConstraint } from "firebase/firestore"; import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle"; import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
@@ -52,14 +52,16 @@ export function useStreamParticles(
const visibilityScopes = useVisibilityScopes(user?.id, networkId); const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE); const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
const [prevStatus, setPrevStatus] = useState(status);
// Every time the user switches back to the closed tab, start with a fresh // Switching back to the closed tab starts a fresh window, avoiding an
// window. Avoids an ever-growing subscription across a long session. // ever-growing subscription across a long session.
useEffect(() => { if (status !== prevStatus) {
setPrevStatus(status);
if (status === "closed") { if (status === "closed") {
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE); setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
} }
}, [status]); }
const whereFilter: QueryFieldFilterConstraint = const whereFilter: QueryFieldFilterConstraint =
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER; status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
+12 -3
View File
@@ -84,21 +84,25 @@ export function useStreamPlayback(
const [state, dispatch] = useReducer(playbackReducer, initialState); const [state, dispatch] = useReducer(playbackReducer, initialState);
// Track the stream ID we've initialized for, to reset when navigating between streams // Track the stream ID we've initialized for, to reset when navigating between streams
const initializedForRef = useRef<string | null>(null); const initializedForRef = useRef<string | null>(null);
// Latest currentIndex for onParticleRemoved (passed to the subscription), so
// it reads the current value without re-subscribing. useEffectEvent can't be
// used — it may not be passed to another hook.
const currentIndexRef = useRef(0);
// --- Firestore change callbacks --- // --- Firestore change callbacks ---
const onParticleAdded = useCallback((particle: Particle) => { const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id }); dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
}, []); }, []);
const onParticleRemoved = useEffectEvent((removed: Particle, updatedChildren: Particle[]) => { const onParticleRemoved = useCallback((removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1); const fallbackIndex = Math.min(currentIndexRef.current, updatedChildren.length - 1);
const fallback = updatedChildren[Math.max(0, fallbackIndex)]; const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({ dispatch({
type: "PARTICLE_REMOVED", type: "PARTICLE_REMOVED",
removedParticleId: removed.id, removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null, fallbackParticleId: fallback?.id ?? null,
}); });
}); }, []);
const { children } = useLiveParticleChildren( const { children } = useLiveParticleChildren(
path, path,
@@ -118,6 +122,11 @@ export function useStreamPlayback(
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null; const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
// Keep the latest-index ref in sync for onParticleRemoved (above).
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
// Fallback init — always sees latest children/state via useEffectEvent // Fallback init — always sees latest children/state via useEffectEvent
const initFallback = useEffectEvent(() => { const initFallback = useEffectEvent(() => {
if (state.initialized || children.length === 0) return; if (state.initialized || children.length === 0) return;
+1 -1
View File
@@ -79,7 +79,7 @@ class SoundEffectsEngine {
*/ */
preload(name: string, url: string): Promise<AudioBuffer | null> { preload(name: string, url: string): Promise<AudioBuffer | null> {
if (this.buffers.has(name)) { if (this.buffers.has(name)) {
return Promise.resolve(this.buffers.get(name)); return Promise.resolve(this.buffers.get(name) ?? null);
} }
const existing = this.loading.get(name); const existing = this.loading.get(name);
if (existing) return existing; if (existing) return existing;