diff --git a/js/src/components/ui/checkbox.tsx b/js/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cec7a77 --- /dev/null +++ b/js/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 84df155..e3f979d 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,7 +1,11 @@ import { useComposeStore } from "@/stores/compose-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCreateParticle } from "@/hooks/use-create-particle"; +import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { TextComposeStep } from "@/features/compose/text-compose-step"; import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; +import { useCallback } from "react"; /** * Renders the current compose step as a fullscreen overlay. @@ -19,6 +23,31 @@ export function ComposeOverlay() { const reviewBlob = useComposeStore((s) => s.reviewBlob); const error = useComposeStore((s) => s.error); + const userEmail = useAuthStore((s) => s.user?.email); + const createParticle = useCreateParticle(); + + const handleStreamSubmit = useCallback( + async (streamName: string, visibleTo: string[]) => { + if (!networkId || !userEmail) return; + + const collectionPath = toFirestoreChildrenPath(particlePath(networkId)); + + await createParticle.mutateAsync({ + collectionPath, + type: "stream", + properties: { + name: streamName, + status: "open", + visible_to: visibleTo, + }, + createdByEmail: userEmail, + }); + + cancel(); + }, + [networkId, userEmail, createParticle, cancel], + ); + if (step === "idle") return null; return ( @@ -42,7 +71,11 @@ export function ComposeOverlay() { /> )} {step === "configuring" && ( - + )} ); diff --git a/js/src/features/compose/configure-stream-step.tsx b/js/src/features/compose/configure-stream-step.tsx index 6ccb477..c6bad18 100644 --- a/js/src/features/compose/configure-stream-step.tsx +++ b/js/src/features/compose/configure-stream-step.tsx @@ -1,43 +1,30 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useNetworks } from "@/hooks/use-networks"; import { cn } from "@/lib/utils"; -import { Check } from "lucide-react"; +import { generateRandomName } from "@/lib/random-name"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface ConfigureStreamStepProps { networkId: string | null; onCancel: () => void; + onSubmit: (streamName: string, visibleTo: string[]) => void; } export function ConfigureStreamStep({ networkId, onCancel, + onSubmit, }: ConfigureStreamStepProps) { - const { data: networks } = useNetworks(); const network = networks?.find((n) => n.id === networkId); const members = network?.humans ?? []; - const [name, setName] = useState(""); + const [name, setName] = useState(() => generateRandomName()); + const [everyone, setEveryone] = useState(true); const [selectedEmails, setSelectedEmails] = useState>(new Set()); - // -1 = name input is focused, 0+ = member list index - const [focusedIndex, setFocusedIndex] = useState(-1); - const nameRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - nameRef.current?.focus(); - }, []); - - // Return focus to the name input when navigating back up - useEffect(() => { - if (focusedIndex === -1) { - nameRef.current?.focus(); - } else { - // Blur the input so arrow keys don't move the cursor - nameRef.current?.blur(); - containerRef.current?.focus(); - } - }, [focusedIndex]); const toggleMember = useCallback((email: string) => { setSelectedEmails((prev) => { @@ -48,11 +35,15 @@ export function ConfigureStreamStep({ }); }, []); - const handleSubmit = useCallback(async () => { + const buildVisibleTo = useCallback((): string[] => { + if (everyone && networkId) return [`network:${networkId}`]; + return Array.from(selectedEmails).map((e) => `human:${e}`); + }, [everyone, networkId, selectedEmails]); + + const handleSubmit = useCallback(() => { if (!name.trim() || !networkId) return; - // TODO: create stream particle, then attach recorded/text content - onCancel(); - }, [name, networkId, onCancel]); + onSubmit(name.trim(), buildVisibleTo()); + }, [name, networkId, onSubmit, buildVisibleTo]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -66,124 +57,105 @@ export function ConfigureStreamStep({ if (e.metaKey || e.ctrlKey) { e.preventDefault(); handleSubmit(); - } else if (focusedIndex === -1 && name.trim() && members.length > 0) { - // Enter in name input → move to member list - e.preventDefault(); - setFocusedIndex(0); - } - return; - - case "ArrowDown": - e.preventDefault(); - setFocusedIndex((i) => Math.min(i + 1, members.length - 1)); - return; - - case "ArrowUp": - e.preventDefault(); - setFocusedIndex((i) => Math.max(i - 1, -1)); - return; - - case " ": - if (focusedIndex >= 0) { - e.preventDefault(); - toggleMember(members[focusedIndex].email); } return; } }, - [onCancel, handleSubmit, focusedIndex, members, name, toggleMember], + [onCancel, handleSubmit, members, name, toggleMember], ); return (
-
+
{/* Stream name */}
- - Stream name + { setName(e.target.value); - setFocusedIndex(-1); }} - onFocus={() => setFocusedIndex(-1)} placeholder="Give it a name..." - className="w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 outline-none focus:border-white/30" + className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0" />
- {/* Member selection */} - {members.length > 0 && ( -
- -
- {members.map((member, index) => { - const isSelected = selectedEmails.has(member.email); - const isFocused = focusedIndex === index; - const initials = member.email_prefix - .slice(0, 2) - .toUpperCase(); - - return ( - - ); - })} + {/* Visibility */} +
+ +
+ {/* Everyone in network */} +
setEveryone((prev) => !prev)} + className={cn( + "flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors", + "text-white/70 hover:bg-white/5", + )} + > + setEveryone(checked === true)} + tabIndex={-1} + className="pointer-events-none" + /> + Everyone in network
+ + {/* Per-member selection */} + {!everyone && members.length > 0 && ( + +
+ {members.map((member, index) => { + const isSelected = selectedEmails.has(member.email); + const initials = member.email_prefix + .slice(0, 2) + .toUpperCase(); + + return ( +
toggleMember(member.email)} + className={cn( + "flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors", + "text-white/70 hover:bg-white/5", + )} + > + + + {initials} + + + {member.email_prefix} + +
+ ); + })} +
+
+ )}
- )} +
{/* Keyboard hints */} -
+
Esc {" "} cancel - - - ↑↓ - {" "} - navigate - - - - Space - {" "} - toggle - ⌘+Enter @@ -191,6 +163,6 @@ export function ConfigureStreamStep({ create
-
+
); } diff --git a/js/src/features/compose/use-compose-keyboard.ts b/js/src/features/compose/use-compose-keyboard.ts index b86a0cd..384d31c 100644 --- a/js/src/features/compose/use-compose-keyboard.ts +++ b/js/src/features/compose/use-compose-keyboard.ts @@ -12,8 +12,22 @@ import { useParams } from "react-router-dom"; */ export function useComposeKeyboard() { const networkId = useParams()["networkId"]; + const recordingMode = useComposeStore((s) => s.recordingMode); + const setMediaStream = useComposeStore((s) => s.setMediaStream); + const finishRecording = useComposeStore((s) => s.finishRecording); + const setError = useComposeStore((s) => s.setError); + const beginRecording = useComposeStore((s) => s.startRecording); + const beginTyping = useComposeStore((s) => s.startTyping); + const cancel = useComposeStore((s) => s.cancel); + const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure); - const { startRecording, stopRecording, cancelRecording } = useRecorder(); + const { startRecording, stopRecording, cancelRecording } = useRecorder({ + mode: recordingMode, + onStreamReady: (stream) => setMediaStream(stream), + onStreamCleanup: () => setMediaStream(null), + onFinish: (blob, durationMs) => finishRecording(blob, durationMs), + onError: (message) => setError(message), + }); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -37,11 +51,11 @@ export function useComposeKeyboard() { if (!networkId) return; if (e.key === "`" && !e.repeat) { e.preventDefault(); - useComposeStore.getState().startRecording(networkId); + beginRecording(networkId); startRecording(); } else if (e.key === "t" || e.key === "T") { e.preventDefault(); - useComposeStore.getState().startTyping(networkId); + beginTyping(networkId); } break; } @@ -50,7 +64,7 @@ export function useComposeKeyboard() { if (e.key === "q" || e.key === "Q" || e.key === "Escape") { e.preventDefault(); cancelRecording(); - useComposeStore.getState().cancel(); + cancel(); } break; } @@ -59,16 +73,16 @@ export function useComposeKeyboard() { if (e.key === "q" || e.key === "Q" || e.key === "Escape") { e.preventDefault(); cancelRecording(); - useComposeStore.getState().cancel(); + cancel(); } else if (e.key === "Enter") { e.preventDefault(); - useComposeStore.getState().advanceToConfigure(); + advanceToConfigure(); } break; } } }, - [networkId, startRecording, cancelRecording], + [networkId, startRecording, cancelRecording, beginRecording, beginTyping, cancel, advanceToConfigure], ); const handleKeyUp = useCallback( diff --git a/js/src/features/compose/use-recorder.ts b/js/src/features/compose/use-recorder.ts index a1a697f..4ee4b22 100644 --- a/js/src/features/compose/use-recorder.ts +++ b/js/src/features/compose/use-recorder.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import { useComposeStore } from "@/stores/compose-store"; +import type { RecordingMode } from "@/stores/compose-store"; const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_FALLBACK_MIME = "video/webm"; @@ -17,47 +17,62 @@ function getMediaMime(mode: "video" | "audio"): string { : VIDEO_FALLBACK_MIME; } +interface UseRecorderOptions { + mode: RecordingMode; + onStreamReady: (stream: MediaStream) => void; + onStreamCleanup: () => void; + onFinish: (blob: Blob, durationMs: number) => void; + onError: (message: string) => void; +} + /** - * Manages MediaRecorder lifecycle and writes results to compose-store. - * - * Does NOT handle uploads or particle creation — that responsibility - * belongs to the configure step after the user finalizes stream metadata. + * Manages MediaRecorder lifecycle. Pure media utility — knows nothing + * about application state. The consumer provides callbacks for all outputs. */ -export function useRecorder() { +export function useRecorder({ + mode, + onStreamReady, + onStreamCleanup, + onFinish, + onError, +}: UseRecorderOptions) { const recorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const startTimeRef = useRef(0); - const recordingMode = useComposeStore((s) => s.recordingMode); - const setMediaStream = useComposeStore((s) => s.setMediaStream); - const setError = useComposeStore((s) => s.setError); - const finishRecording = useComposeStore((s) => s.finishRecording); + // Refs to avoid stale closures in MediaRecorder event handlers + const onStreamCleanupRef = useRef(onStreamCleanup); + const onFinishRef = useRef(onFinish); + const onErrorRef = useRef(onError); + useEffect(() => { + onStreamCleanupRef.current = onStreamCleanup; + onFinishRef.current = onFinish; + onErrorRef.current = onError; + }); const stopTracks = useCallback(() => { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; recorderRef.current = null; chunksRef.current = []; - setMediaStream(null); - }, [setMediaStream]); + onStreamCleanupRef.current(); + }, []); const startRecording = useCallback(async () => { try { const constraints = - recordingMode === "video" - ? { video: true, audio: true } - : { audio: true }; + mode === "video" ? { video: true, audio: true } : { audio: true }; const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); streamRef.current = mediaStream; - setMediaStream(mediaStream); + onStreamReady(mediaStream); chunksRef.current = []; startTimeRef.current = Date.now(); - const mime = getMediaMime(recordingMode); + const mime = getMediaMime(mode); const recorder = new MediaRecorder(mediaStream, { mimeType: mime }); recorderRef.current = recorder; @@ -71,18 +86,18 @@ export function useRecorder() { stopTracks(); if (blob.size > 0) { - finishRecording(blob, durationMs); + onFinishRef.current(blob, durationMs); } }; recorder.start(); } catch (err) { stopTracks(); - setError( + onErrorRef.current( err instanceof Error ? err.message : "Failed to start recording", ); } - }, [recordingMode, setMediaStream, setError, finishRecording, stopTracks]); + }, [mode, onStreamReady, stopTracks]); const stopRecording = useCallback(() => { if (recorderRef.current?.state === "recording") { diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 655a31f..d373e81 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -1,26 +1,73 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { Radio } from "lucide-react"; import { useLiveParticleChildren } from "@/hooks/use-particle"; -import type { ParticlePath } from "@/lib/particle-path"; +import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Progress } from "@/components/ui/progress"; +import { Small } from "@/components/ui/typography"; import ControlsIndicator from "@/features/compose/controls-indicator"; +import type { Particle, StreamProperties } from "@/api/types"; + +function StreamRow({ + particle, + onClick, +}: { + particle: Particle & { type: "stream"; properties: StreamProperties }; + onClick: () => void; +}) { + const initials = particle.properties.name.slice(0, 2).toUpperCase(); + + return ( + + ); +} interface ParticleListViewProps { path: ParticlePath; } /** - * Grid/list of child particles for a container (folder, stream root, or network root). + * List of stream particles for a container (network root, folder, etc.). */ export function ParticleListView({ path }: ParticleListViewProps) { const { children, isLoading } = useLiveParticleChildren(path); + const { networkId } = parseParticlePath(path); + const navigate = useNavigate(); + + const streams = useMemo( + () => children.filter((c) => c.type === "stream"), + [children], + ); if (isLoading) { - return ( -
-

Loading particles...

-
- ); + return ; } - if (children.length === 0) { + if (streams.length === 0) { return (
@@ -29,16 +76,18 @@ export function ParticleListView({ path }: ParticleListViewProps) { } return ( -
- {children.map((child) => ( -
-

{child.id}

-

{child.type}

-
- ))} -
+ +
+ {streams.map((stream, index) => ( +
+ navigate(`/${networkId}/${stream.id}`)} + /> + {index < streams.length - 1 && } +
+ ))} +
+
); } diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index d6df5b0..f86a915 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -7,7 +7,6 @@ interface CreateParticleParams { type: T; properties: ParticlePropertiesMap[T]; createdByEmail: string; - visibleTo: string[]; } export function useCreateParticle() { @@ -18,7 +17,6 @@ export function useCreateParticle() { params.type, params.properties, params.createdByEmail, - params.visibleTo, ), }); } diff --git a/js/src/lib/random-name.ts b/js/src/lib/random-name.ts new file mode 100644 index 0000000..7053df5 --- /dev/null +++ b/js/src/lib/random-name.ts @@ -0,0 +1,19 @@ +const ADJECTIVES = [ + "amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle", + "hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal", + "pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty", + "bright", "clear", "deep", "fresh", "grand", "swift", +]; + +const NOUNS = [ + "arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor", + "iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal", + "quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith", + "brook", "cliff", "delta", "frost", "glow", "reef", +]; + +export function generateRandomName(): string { + const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + return `${adj}-${noun}`; +}