diff --git a/js/desktop/src/components/audio/use-audio-source.ts b/js/desktop/src/components/audio/use-audio-source.ts index 7c4071b..13ac7b5 100644 --- a/js/desktop/src/components/audio/use-audio-source.ts +++ b/js/desktop/src/components/audio/use-audio-source.ts @@ -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; diff --git a/js/desktop/src/components/screen-source-picker.tsx b/js/desktop/src/components/screen-source-picker.tsx index 7a11b15..58e145d 100644 --- a/js/desktop/src/components/screen-source-picker.tsx +++ b/js/desktop/src/components/screen-source-picker.tsx @@ -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:")); diff --git a/js/desktop/src/features/attachments/attachment-lightbox.tsx b/js/desktop/src/features/attachments/attachment-lightbox.tsx index 77ea49e..700a561 100644 --- a/js/desktop/src/features/attachments/attachment-lightbox.tsx +++ b/js/desktop/src/features/attachments/attachment-lightbox.tsx @@ -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(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/"); diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index 8657b44..6a17763 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -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 --- diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index fbdcf02..c35f785 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -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(null); - const [objectUrl, setObjectUrl] = useState(null); + const objectUrl = useObjectUrl(blob); const audioElRef = useRef(null); const [audioEl, setAudioEl] = useState(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) { diff --git a/js/desktop/src/features/particles/media-particle-view.tsx b/js/desktop/src/features/particles/media-particle-view.tsx index 21ba7c3..62b4b07 100644 --- a/js/desktop/src/features/particles/media-particle-view.tsx +++ b/js/desktop/src/features/particles/media-particle-view.tsx @@ -33,22 +33,19 @@ export const MediaParticleView = forwardRef 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