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());
useEffect(() => {
if (!source) {
setAudioSource(null);
return;
}
// Build the source node (creating an AudioContext as needed) plus optional
// teardown for contexts we own; the branches converge on one setState.
let result: AudioSource | null = null;
let cleanup: (() => void) | undefined;
if (source instanceof MediaStream) {
const ctx = new AudioContext();
ctx.resume();
const sourceNode = ctx.createMediaStreamSource(source);
setAudioSource({ sourceNode, ctx });
return () => {
ctx.close();
};
result = { sourceNode, ctx };
cleanup = () => ctx.close();
} else if (source) {
// 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
const cached = elementSourceCache.current.get(source);
if (cached) {
cached.ctx.resume();
setAudioSource(cached);
return;
}
// Publishing an imperatively-created Web Audio node — external-resource
// sync, not a re-render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAudioSource(result);
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 });
setAudioSource({ sourceNode, ctx });
return () => {
ctx.close();
elementSourceCache.current.delete(source);
};
return cleanup;
}, [source]);
return audioSource;
@@ -23,16 +23,13 @@ export function ScreenSourcePicker({
getSources().then((result) => {
setSources(result);
setLoading(false);
// Auto-select if there's only one source.
if (result.length === 1) {
setSelectedId(result[0].id);
}
});
}, [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 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 {
ChevronLeft,
@@ -10,6 +10,7 @@ import {
X,
} from "lucide-react";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { useObjectUrl } from "@/hooks/use-object-url";
import { Button } from "@/components/ui/button";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
import { platform } from "@/lib/platform";
@@ -71,17 +72,10 @@ export function AttachmentLightbox({
const { data: remoteUrl, isLoading: isRemoteLoading } =
useDownloadUrl(remoteObjectId);
// Local items get a fresh blob URL per item, revoked on change/close.
const [localUrl, setLocalUrl] = useState<string | null>(null);
useEffect(() => {
if (current?.source.kind !== "local") {
setLocalUrl(null);
return;
}
const url = URL.createObjectURL(current.source.file);
setLocalUrl(url);
return () => URL.revokeObjectURL(url);
}, [current?.id, current?.source.kind]);
// Local items resolve to a blob URL; remote items use the signed-URL cache.
const localFile =
current?.source.kind === "local" ? current.source.file : null;
const localUrl = useObjectUrl(localFile);
const url =
current?.source.kind === "remote"
@@ -90,18 +84,21 @@ export function AttachmentLightbox({
const canDownload = current?.source.kind === "remote" && !!url;
const goTo = (delta: number) => {
if (openIndex === null || items.length === 0) return;
const next = (openIndex + delta + items.length) % items.length;
onOpenChange(next);
};
const goTo = useCallback(
(delta: number) => {
if (openIndex === null || items.length === 0) return;
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;
platform.attachment.download(url, current.filename);
};
}, [current, url]);
const handleRemove = () => {
const handleRemove = useCallback(() => {
if (!current || !onRemove) return;
const wasLast = items.length <= 1;
const wasAtEnd = openIndex === items.length - 1;
@@ -112,7 +109,7 @@ export function AttachmentLightbox({
onOpenChange(items.length - 2);
}
// Otherwise openIndex stays — the next item shifts into its place.
};
}, [current, onRemove, items.length, openIndex, onOpenChange]);
useSuspendPlayback(isOpen, "attachment-lightbox");
@@ -154,7 +151,7 @@ export function AttachmentLightbox({
};
window.addEventListener("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 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 { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
@@ -80,15 +80,17 @@ export function ComposeOverlay({
const invalidateUsage = useInvalidateNetworkUsage();
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 recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const quotaExhaustedRef = useRef(quotaExhausted);
quotaExhaustedRef.current = quotaExhausted;
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) => {
stepRef.current = next;
@@ -361,8 +363,8 @@ export function ComposeOverlay({
return false;
}, [cancel]);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
// Reply mode: create particle directly under targetPath.
const onSubmitReply = useCallback(async () => {
if (!targetPath || !userId || stepRef.current === "submitting") return;
setStepSync("submitting");
try {
@@ -371,7 +373,7 @@ export function ComposeOverlay({
} catch (err) {
if (!handleQuotaError(err)) throw err;
}
});
}, [targetPath, userId, setStepSync, createChildParticle, cancel, handleQuotaError]);
// New stream mode: create stream + first child
const handleStreamSubmit = useCallback(
@@ -397,7 +399,7 @@ export function ComposeOverlay({
if (!handleQuotaError(err)) throw err;
}
},
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError],
[networkId, userId, createStream, createChildParticle, cancel, handleQuotaError, setStepSync],
);
// --- Compose intent handlers ---
@@ -468,20 +470,25 @@ export function ComposeOverlay({
// executes the matching handler and clears the intent. Keyboard handlers
// call the same handlers directly without a store round-trip.
const intent = useComposeIntentStore((s) => s.intent);
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(() => {
if (!intent) return;
switch (intent.kind) {
case "record": handleRecordIntent(); break;
case "text": handleTextIntent(); break;
case "stop": handleStopIntent(); break;
case "cancel": handleCancelIntent(); break;
case "send": handleSendIntent(); break;
}
clearIntent();
}, [intent, handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
return useComposeIntentStore.subscribe((state, prev) => {
const intent = state.intent;
if (!intent || intent === prev.intent) return;
switch (intent.kind) {
case "record": handleRecordIntent(); break;
case "text": handleTextIntent(); break;
case "stop": handleStopIntent(); break;
case "cancel": handleCancelIntent(); break;
case "send": handleSendIntent(); break;
}
clearIntent();
});
}, [handleRecordIntent, handleTextIntent, handleStopIntent, handleCancelIntent, handleSendIntent, clearIntent]);
// --- Keyboard handling ---
@@ -3,6 +3,7 @@ import { Paperclip } from "lucide-react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
import { useObjectUrl } from "@/hooks/use-object-url";
import { AttachmentStrip } from "@/features/compose/attachment-strip";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
import { cn } from "@/lib/utils";
@@ -65,23 +66,11 @@ function ReviewPlayback({
mirror?: boolean;
objectFit?: "cover" | "contain";
}) {
const urlRef = useRef<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const objectUrl = useObjectUrl(blob);
const audioElRef = useRef<HTMLAudioElement | null>(null);
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
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 (isVideo) {
@@ -33,22 +33,19 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
onEnded,
onProgress,
}, ref) {
// Prefer the worker-produced iOS-playable variant when present so desktop
// and mobile read the same canonical asset. Falls back to the original.
// Pin the choice for the lifetime of this particle: if a transcoded variant
// arrives via Firestore mid-playback, swapping the <video> src would restart
// playback from 0. Keep whatever we picked first; the original plays fine in
// Electron, and the transcoded variant will be picked up on the next view.
const pickedSourceRef = useRef<{ id: string; objectId: string; mime: string } | null>(null);
if (pickedSourceRef.current?.id !== particle.id) {
pickedSourceRef.current = {
id: particle.id,
objectId: particle.properties.transcoded_object_id ?? particle.properties.object_id,
mime: particle.properties.transcoded_mime_type ?? particle.properties.mime_type,
};
}
const activeObjectId = pickedSourceRef.current.objectId;
const activeMime = pickedSourceRef.current.mime;
// Prefer the worker-produced iOS-playable variant when present so desktop and
// mobile read the same canonical asset, falling back to the original. Pinned
// on mount (the parent keys this component by particle.id, so a new particle
// remounts and re-picks): if a transcoded variant arrives via Firestore for
// the same particle, swapping the <video> src would restart playback from 0.
const [pickedSource] = useState(() => ({
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 { data: url, error } = useDownloadUrl(activeObjectId);
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 {
Radio,
@@ -186,7 +186,9 @@ const StreamRow = memo(function StreamRow({
? getMessagePreview(latestChild)
: 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 =
latestChild &&
@@ -254,12 +256,12 @@ const StreamRow = memo(function StreamRow({
</div>
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
{createElement(typeIcon, {
className: cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
),
})}
<Small
className={cn(
"truncate",
@@ -34,7 +34,10 @@ export function StreamMembersOverlay({
const network = useNetwork(networkId);
const humans = network?.humans ?? [];
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(
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
@@ -51,24 +51,26 @@ function useExitCountdown(
disabled: boolean,
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(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Start the countdown when playback ends; cancel it otherwise.
if (status !== prevStatus) {
setPrevStatus(status);
setRemainingMs(status === "ended" ? EXIT_DELAY_MS : null);
}
const isCountingDown = remainingMs !== null && remainingMs > 0;
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || disabled) return;
if (!isCountingDown || disabled) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
@@ -79,7 +81,7 @@ function useExitCountdown(
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, disabled]);
}, [isCountingDown, disabled]);
// Navigate once countdown hits zero
useEffect(() => {
@@ -197,12 +199,13 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const reactions = getReactions(currentParticle);
toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions);
}, [authedUser, currentParticle]);
}, [authedUser, currentParticle, networkId, streamParticle.id]);
const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0);
const [prevParticleId, setPrevParticleId] = useState(currentParticle?.id);
const [showKeybindings, setShowKeybindings] = useState(false);
const [textReactionOpen, setTextReactionOpen] = useState(false);
@@ -286,10 +289,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
handleExitNavigate,
);
// Reset progress when particle changes
useEffect(() => {
// Reset progress when the particle changes.
if (currentParticle?.id !== prevParticleId) {
setPrevParticleId(currentParticle?.id);
setProgress(0);
}, [currentParticle?.id]);
}
const handleParticleCreated = useCallback((particleId: string) => {
if (currentIndex === -1) return;
@@ -14,13 +14,19 @@ interface TextReactionInputProps {
export function TextReactionInput({ open, onSubmit, onClose }: TextReactionInputProps) {
const [value, setValue] = useState("");
const [prevOpen, setPrevOpen] = useState(open);
const inputRef = useRef<HTMLInputElement>(null);
useSuspendPlayback(open, "text-reaction");
// Clear the input each time the popup opens.
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setValue("");
}
useEffect(() => {
if (!open) return;
setValue("");
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [open]);
@@ -1,4 +1,4 @@
import { useMemo, useRef } from "react";
import { useMemo, useState } from "react";
import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
@@ -41,35 +41,40 @@ export function TranscriptOverlay({
const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
// Remember the last spoken word so highlights hold during pauses
const lastSpokenWordRef = useRef<Word | null>(null);
if (activeWord) {
lastSpokenWordRef.current = activeWord;
// Remember the last spoken word so highlights hold during pauses.
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
if (activeWord && activeWord !== lastSpokenWord) {
setLastSpokenWord(activeWord);
}
const highlightWord = activeWord ?? lastSpokenWordRef.current;
const highlightWord = activeWord ?? lastSpokenWord;
const lastChunkRef = useRef<Word[] | null>(null);
// Find which chunk contains the active word, holding the last one during pauses
const activeChunk = useMemo(() => {
if (activeWord) {
for (const chunk of chunks) {
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
lastChunkRef.current = chunk;
return chunk;
}
}
}
// 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;
// The chunk currently being spoken (null during a pause or if not found).
const spokenChunk = useMemo(() => {
if (!activeWord) return null;
return (
chunks.find((chunk) =>
chunk.some(
(w) => w.start === activeWord.start && w.end === activeWord.end,
),
) ?? null
);
}, [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;
return (
@@ -34,12 +34,20 @@ function usePreviewStream(
): { stream: MediaStream | null; error: string | null } {
const [stream, setStream] = useState<MediaStream | 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) {
setStream(null);
return;
setError(null);
}
}
useEffect(() => {
if (!enabled) return;
let cancelled = false;
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[]>([]);
useEffect(() => {
if (!client || !channelId) {
setPresence([]);
setMessages([]);
return;
}
if (!client || !channelId) return;
client.subscribe(channelId);
@@ -66,6 +62,9 @@ export function useChannel(channelId: string | null): UseChannelResult {
client.off(channelId, "leave", onLeave);
client.off(channelId, "message", onMessage);
client.unsubscribe(channelId);
// Clear on teardown so a new channel doesn't briefly show stale data.
setPresence([]);
setMessages([]);
};
}, [client, channelId]);
+5 -2
View File
@@ -10,9 +10,12 @@ export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions)
const inputRef = useRef<HTMLInputElement | null>(null);
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);
onFilesRef.current = onFilesSelected;
useEffect(() => {
onFilesRef.current = onFilesSelected;
}, [onFilesSelected]);
// Hidden file input element
useEffect(() => {
+17 -15
View File
@@ -25,21 +25,23 @@ export function useMediaDevices(): UseMediaDevicesResult {
useState<PermissionState>("unknown");
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === "audioinput" || d.kind === "videoinput") &&
d.label.length > 0,
);
if (hasLabels) setPermissionState("granted");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to list devices");
}
const refresh = useCallback(() => {
return navigator.mediaDevices
.enumerateDevices()
.then((list) => {
setDevices(list);
// If at least one input device has a non-empty label, permission
// has been granted at some point for that device kind.
const hasLabels = list.some(
(d) =>
(d.kind === "audioinput" || d.kind === "videoinput") &&
d.label.length > 0,
);
if (hasLabels) setPermissionState("granted");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to list devices");
});
}, []);
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 {
subscribeToParticle,
subscribeToParticleChildren,
@@ -27,10 +27,6 @@ export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle(
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]);
return { particle, isLoading, error };
@@ -83,16 +85,17 @@ export function useLiveParticleChildren(
const [isLoading, setIsLoading] = useState(true);
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(() => {
if (!path) {
setChildren([]);
setIsLoading(false);
return;
}
onAddedRef.current = onAdded;
onRemovedRef.current = onRemoved;
}, [onAdded, onRemoved]);
setIsLoading(true);
setError(null);
setChildren([]);
useEffect(() => {
if (!path) return;
const collectionPath = toFirestoreChildrenPath(path);
@@ -111,15 +114,27 @@ export function useLiveParticleChildren(
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
onAdded: (child: Particle) => onAddedRef.current?.(child),
onRemoved: (child: Particle, updatedChildren: Particle[]) =>
onRemovedRef.current?.(child, updatedChildren),
whereFilter,
limit,
}
);
return unsubscribe;
}, [path, whereFilter, limit]);
return () => {
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 };
}
@@ -134,9 +149,6 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
toFirestoreChildrenPath(path),
(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]);
return { latestChild, isLoading };
+14 -10
View File
@@ -12,19 +12,23 @@ export function useStreamKeyboardNav({
enabled,
onNavigate,
}: 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 setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
// Initialize selection when streams first load; clear if streams become empty.
// Do NOT reset on every Firestore update — that would scroll the list to the top.
useEffect(() => {
setSelectedIndex((prev) => {
if (streams.length === 0) return null;
if (prev === null) return 0;
return prev;
});
}, [streams.length]);
// Select the first stream once they load and clear when empty — but not on
// every Firestore update, which would scroll the list back to the top.
if (streams.length !== prevStreamCount) {
setPrevStreamCount(streams.length);
if (streams.length === 0) {
setSelectedIndex(null);
} else if (selectedIndex === null) {
setSelectedIndex(0);
}
}
useEffect(() => {
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 { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
@@ -52,14 +52,16 @@ export function useStreamParticles(
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
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
// window. Avoids an ever-growing subscription across a long session.
useEffect(() => {
// Switching back to the closed tab starts a fresh window, avoiding an
// ever-growing subscription across a long session.
if (status !== prevStatus) {
setPrevStatus(status);
if (status === "closed") {
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
}
}, [status]);
}
const whereFilter: QueryFieldFilterConstraint =
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);
// Track the stream ID we've initialized for, to reset when navigating between streams
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 ---
const onParticleAdded = useCallback((particle: Particle) => {
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
}, []);
const onParticleRemoved = useEffectEvent((removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
const onParticleRemoved = useCallback((removed: Particle, updatedChildren: Particle[]) => {
const fallbackIndex = Math.min(currentIndexRef.current, updatedChildren.length - 1);
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
dispatch({
type: "PARTICLE_REMOVED",
removedParticleId: removed.id,
fallbackParticleId: fallback?.id ?? null,
});
});
}, []);
const { children } = useLiveParticleChildren(
path,
@@ -118,6 +122,11 @@ export function useStreamPlayback(
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
const initFallback = useEffectEvent(() => {
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> {
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);
if (existing) return existing;