diff --git a/js/src/App.tsx b/js/src/App.tsx index b55f52a..8e01b62 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { HashRouter, Routes, Route, Outlet } from "react-router-dom"; +import { HashRouter, Routes, Route } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx new file mode 100644 index 0000000..84df155 --- /dev/null +++ b/js/src/features/compose/compose-overlay.tsx @@ -0,0 +1,49 @@ +import { useComposeStore } from "@/stores/compose-store"; +import { RecordingOverlay } from "@/features/compose/recording-overlay"; +import { TextComposeStep } from "@/features/compose/text-compose-step"; +import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; + +/** + * Renders the current compose step as a fullscreen overlay. + * Returns null when idle — zero cost when not composing. + */ +export function ComposeOverlay() { + const step = useComposeStore((s) => s.step); + const cancel = useComposeStore((s) => s.cancel); + const textContent = useComposeStore((s) => s.textContent); + const setTextContent = useComposeStore((s) => s.setTextContent); + const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure); + const networkId = useComposeStore((s) => s.networkId); + const mediaStream = useComposeStore((s) => s.mediaStream); + const recordingMode = useComposeStore((s) => s.recordingMode); + const reviewBlob = useComposeStore((s) => s.reviewBlob); + const error = useComposeStore((s) => s.error); + + if (step === "idle") return null; + + return ( + <> + {(step === "recording" || step === "reviewing") && ( + + )} + {step === "typing" && ( + + )} + {step === "configuring" && ( + + )} + + ); +} diff --git a/js/src/features/compose/configure-stream-step.tsx b/js/src/features/compose/configure-stream-step.tsx new file mode 100644 index 0000000..6ccb477 --- /dev/null +++ b/js/src/features/compose/configure-stream-step.tsx @@ -0,0 +1,196 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useNetworks } from "@/hooks/use-networks"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; + +interface ConfigureStreamStepProps { + networkId: string | null; + onCancel: () => void; +} + +export function ConfigureStreamStep({ + networkId, + onCancel, +}: ConfigureStreamStepProps) { + + const { data: networks } = useNetworks(); + const network = networks?.find((n) => n.id === networkId); + const members = network?.humans ?? []; + + const [name, setName] = useState(""); + 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) => { + const next = new Set(prev); + if (next.has(email)) next.delete(email); + else next.add(email); + return next; + }); + }, []); + + const handleSubmit = useCallback(async () => { + if (!name.trim() || !networkId) return; + // TODO: create stream particle, then attach recorded/text content + onCancel(); + }, [name, networkId, onCancel]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Escape": + e.preventDefault(); + onCancel(); + return; + + case "Enter": + 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], + ); + + return ( +
+
+ {/* 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" + /> +
+ + {/* 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 ( + + ); + })} +
+
+ )} +
+ + {/* Keyboard hints */} +
+ + + Esc + {" "} + cancel + + + + ↑↓ + {" "} + navigate + + + + Space + {" "} + toggle + + + + ⌘+Enter + {" "} + create + +
+
+ ); +} diff --git a/js/src/features/send/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx similarity index 84% rename from js/src/features/send/controls-indicator.tsx rename to js/src/features/compose/controls-indicator.tsx index 3b1f8ed..e77d5d1 100644 --- a/js/src/features/send/controls-indicator.tsx +++ b/js/src/features/compose/controls-indicator.tsx @@ -1,5 +1,5 @@ -import { Video, Mic, ChevronDown } from "lucide-react"; -import { useRecordingStore } from "@/stores/recording-store"; +import { Video, Mic } from "lucide-react"; +import { useComposeStore } from "@/stores/compose-store"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -7,8 +7,8 @@ interface ControlsIndicatorProps { type: "reply" | "new"; } export default function ControlsIndicator({ type }: ControlsIndicatorProps) { - const recordingMode = useRecordingStore((s) => s.recordingMode); - const setRecordingMode = useRecordingStore((s) => s.setRecordingMode); + const recordingMode = useComposeStore((s) => s.recordingMode); + const setRecordingMode = useComposeStore((s) => s.setRecordingMode); return (
diff --git a/js/src/features/send/recording-overlay.tsx b/js/src/features/compose/recording-overlay.tsx similarity index 74% rename from js/src/features/send/recording-overlay.tsx rename to js/src/features/compose/recording-overlay.tsx index 9d8577e..0cb6c06 100644 --- a/js/src/features/send/recording-overlay.tsx +++ b/js/src/features/compose/recording-overlay.tsx @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { useRecordingStore } from "@/stores/recording-store"; +import type { ComposeStep, RecordingMode } from "@/stores/compose-store"; import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { useAudioSource } from "@/components/audio/use-audio-source"; interface RecordingOverlayProps { + step: ComposeStep; + mediaStream: MediaStream | null; + recordingMode: RecordingMode; + reviewBlob: Blob | null; + error: string | null; onClose: () => void; } @@ -87,24 +92,17 @@ function ReviewPlayback({ ); } -export function RecordingOverlay({ onClose }: RecordingOverlayProps) { - const status = useRecordingStore((s) => s.status); - const mediaStream = useRecordingStore((s) => s.mediaStream); - const recordingMode = useRecordingStore((s) => s.recordingMode); - const reviewBlob = useRecordingStore((s) => s.reviewBlob); +export function RecordingOverlay({ + step, + mediaStream, + recordingMode, + reviewBlob, + error, + onClose, +}: RecordingOverlayProps) { const videoRef = useRef(null); - const hasBeenActiveRef = useRef(false); const recordingAudioSource = useAudioSource(mediaStream ?? null); - // Track whether we've entered an active state at least once - if ( - status === "recording" || - status === "uploading" || - status === "reviewing" - ) { - hasBeenActiveRef.current = true; - } - // Set video srcObject for live preview useEffect(() => { if (videoRef.current && mediaStream && recordingMode === "video") { @@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { } }, [mediaStream, recordingMode]); - // Auto-close when status returns to idle after being active - useEffect(() => { - if (!hasBeenActiveRef.current) return; - if (status === "idle") { - onClose(); - } - }, [status, onClose]); - // Auto-close after error with a brief delay useEffect(() => { - if (status !== "error") return; + if (!error) return; const timeout = setTimeout(onClose, 1500); return () => clearTimeout(timeout); - }, [status, onClose]); + }, [error, onClose]); - const isUploading = status === "uploading"; - const isReviewing = status === "reviewing"; - const isRecording = status === "recording"; - - // Loading: status is recording but media stream hasn't arrived yet + const isReviewing = step === "reviewing"; + const isRecording = step === "recording"; const isLoading = isRecording && !mediaStream; return ( @@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { /> )} - {/* Dimmed overlay when uploading */} - {isUploading &&
} - - {/* Top center: recording indicator / uploading */} + {/* Top center: recording indicator */}
- {isUploading ? ( -
- - Sending... -
- ) : isRecording && !isLoading ? ( + {isRecording && !isLoading ? ( ) : isReviewing ? (
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { ) : null}
- {/* Center: audio level bars (recording with active stream) */} + {/* Bottom center: audio level bars (recording with active stream) */} {isRecording && recordingAudioSource && ( -
+
)} @@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { Enter {" "} - to send + next @@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { )} {/* Error state */} - {status === "error" && ( + {error && (
- {useRecordingStore.getState().error ?? "Recording failed"} + {error}
)}
diff --git a/js/src/features/send/text-compose-overlay.tsx b/js/src/features/compose/text-compose-step.tsx similarity index 56% rename from js/src/features/send/text-compose-overlay.tsx rename to js/src/features/compose/text-compose-step.tsx index 73365bf..c3fcfe8 100644 --- a/js/src/features/send/text-compose-overlay.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -1,11 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; -import { apiClient } from "@/api/client"; -import { useAppStore } from "@/stores/app-store"; -interface TextComposeOverlayProps { - streamId: string; - onClose: () => void; +interface TextComposeStepProps { + textContent: string; + onTextChange: (text: string) => void; + onAdvance: () => void; + onCancel: () => void; } function getTextStyle(length: number) { @@ -15,61 +15,41 @@ function getTextStyle(length: number) { return { size: "text-lg", weight: "font-normal" }; } -export function TextComposeOverlay({ - streamId, - onClose, -}: TextComposeOverlayProps) { - const [content, setContent] = useState(""); - const [sending, setSending] = useState(false); +export function TextComposeStep({ + textContent, + onTextChange, + onAdvance, + onCancel, +}: TextComposeStepProps) { const textareaRef = useRef(null); - const addParticleToStream = useAppStore((s) => s.addParticleToStream); useEffect(() => { textareaRef.current?.focus(); }, []); - const handleSend = useCallback(async () => { - const trimmed = content.trim(); - if (!trimmed || sending) return; - - setSending(true); - try { - const particle = await apiClient.createStreamParticle(streamId, { - type: "text", - data: { content: trimmed }, - }); - addParticleToStream(streamId, particle); - onClose(); - } catch { - setSending(false); - } - }, [content, sending, streamId, addParticleToStream, onClose]); - const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - e.stopPropagation(); - onClose(); + onCancel(); } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - handleSend(); + if (textContent.trim()) onAdvance(); } }, - [onClose, handleSend], + [onCancel, onAdvance, textContent], ); - const style = getTextStyle(content.length); + const style = getTextStyle(textContent.length); return (