From 2c2e56d5e91e7e4d999fb43afa155cecbca366f8 Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 18 Mar 2026 08:48:38 -0700 Subject: [PATCH 01/39] chore: only set visibility for container particles --- js/src/api/types.ts | 9 ++++++--- js/src/lib/firestore-particles.ts | 4 ---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 1583c24..7660817 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -67,12 +67,18 @@ export const StreamPropertiesSchema = z.object({ name: z.string(), status: z.enum(["open", "closed"]), description: z.string().optional(), + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()) }); export type StreamProperties = z.infer; export const FolderPropertiesSchema = z.object({ name: z.string(), color: z.string().optional(), + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()) }); export type FolderProperties = z.infer; @@ -128,9 +134,6 @@ const ParticleBaseSchema = z.object({ created_at: z.coerce.date(), created_by_email: z.string().email(), updated_at: z.coerce.date().optional(), - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John - // e.g. ["network:123"] - visible to everyone in the network - visible_to: z.array(z.string()) }); export const ParticleSchema = z.discriminatedUnion("type", [ diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 961860b..a08ecc7 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -92,7 +92,6 @@ export async function createParticle( type: T, properties: ParticlePropertiesMap[T], createdByEmail: string, - visibleTo: string[], ): Promise { const particle: Particle = ParticleSchema.parse({ id: "", // ignored by toFirestore, but needed to satisfy the type @@ -101,7 +100,6 @@ export async function createParticle( created_at: new Date(), created_by_email: createdByEmail, updated_at: null, - visible_to: visibleTo, }); const ref = await addDoc(typedCollection(collectionPath), particle); return ref.id; @@ -111,7 +109,6 @@ export async function createParticle( export async function updateParticle( docPath: string, properties: Partial, - visibleTo?: string[], ): Promise { const particleRef = typedDoc(docPath); // Take the partial and create a new object with dot notation @@ -123,6 +120,5 @@ export async function updateParticle( await updateDoc(particleRef, { ...updatedProperties, updated_at: serverTimestamp(), - ...(visibleTo ? { visible_to: visibleTo } : {}), }); } -- 2.54.0 From 64d73365474005bacc4491588008304852fb4251 Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 18 Mar 2026 09:48:51 -0700 Subject: [PATCH 02/39] create reusable controls indicator for reply or new --- js/src/features/send/controls-indicator.tsx | 64 +++++++++++++++++++++ js/src/features/send/reply-indicator.tsx | 53 ----------------- 2 files changed, 64 insertions(+), 53 deletions(-) create mode 100644 js/src/features/send/controls-indicator.tsx delete mode 100644 js/src/features/send/reply-indicator.tsx diff --git a/js/src/features/send/controls-indicator.tsx b/js/src/features/send/controls-indicator.tsx new file mode 100644 index 0000000..3b1f8ed --- /dev/null +++ b/js/src/features/send/controls-indicator.tsx @@ -0,0 +1,64 @@ +import { Video, Mic, ChevronDown } from "lucide-react"; +import { useRecordingStore } from "@/stores/recording-store"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +interface ControlsIndicatorProps { + type: "reply" | "new"; +} +export default function ControlsIndicator({ type }: ControlsIndicatorProps) { + const recordingMode = useRecordingStore((s) => s.recordingMode); + const setRecordingMode = useRecordingStore((s) => s.setRecordingMode); + + return ( +
+ +
+ Hold{" "} + + ` + {" "} + to {type === "reply" ? "reply " : "start "} + · Press{" "} + + T + {" "} + for text +
+
+ ); +} diff --git a/js/src/features/send/reply-indicator.tsx b/js/src/features/send/reply-indicator.tsx deleted file mode 100644 index fd65a78..0000000 --- a/js/src/features/send/reply-indicator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Video, Mic } from "lucide-react"; -import { useRecordingStore } from "@/stores/recording-store"; -import { cn } from "@/lib/utils"; - -export function ReplyIndicator() { - const recordingMode = useRecordingStore((s) => s.recordingMode); - const setRecordingMode = useRecordingStore((s) => s.setRecordingMode); - - return ( -
- -
- Hold{" "} - - ` - {" "} - to reply · Press{" "} - - T - {" "} - to text -
-
- ); -} -- 2.54.0 From 0149450bc4fc392adaac00a479eb8930a708803f Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 18 Mar 2026 09:49:12 -0700 Subject: [PATCH 03/39] compress the size of top bar --- js/src/pages/path-resolver.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/js/src/pages/path-resolver.tsx b/js/src/pages/path-resolver.tsx index 8b785c8..482e40f 100644 --- a/js/src/pages/path-resolver.tsx +++ b/js/src/pages/path-resolver.tsx @@ -5,7 +5,6 @@ import { ParticleListView } from "@/features/particles/particle-list-view"; import { ParticleViewResolver } from "@/features/particles/particle-view-resolver"; import { WindowControls } from "@/components/window-controls"; import { Button } from "@/components/ui/button"; -import { Muted } from "@/components/ui/typography"; import { Breadcrumb, BreadcrumbItem, @@ -42,16 +41,15 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { function TopBar({ segments }: { segments: string[] }) { const navigate = useNavigate(); - const user = useAuthStore((s) => s.user); const networkId = segments[0] ?? null; return ( -
+
- + {segments.length === 0 ? ( @@ -71,7 +69,7 @@ function TopBar({ segments }: { segments: string[] }) { {networkId && ( - + {segments.length === 1 ? ( @@ -95,7 +93,7 @@ function TopBar({ segments }: { segments: string[] }) { return ( - + {isLast ? ( {segment} ) : ( @@ -114,7 +112,7 @@ function TopBar({ segments }: { segments: string[] }) {
- {user && {user.email_prefix}} + -
-
-
-

- No particles yet. Hold ` to record the first one. -

-
-
- - {stream.name} - - -
- - {composingText && streamId && ( - setComposingText(false)} - /> - )} - - {showRecordingOverlay && ( - - )} -
- ); - } - - const currentParticle = particles[currentIndex]; - - return ( -
- {/* Particle content — fills entire viewport */} -
- {currentParticle && ( - - )} -
- - {/* Top overlay: progress bars + back button */} -
-
- -
-
- -
-
- - {/* Top center overlay for particle author avatar and name */} -
-
- {currentParticle && ( - - - {currentParticle.created_by_email - .split("@")[0] - .slice(0, 2) - .toUpperCase()} - - - )} - - {currentParticle?.created_by_email.split("@")[0]} - · - {stream.name} - -
-
- - {/* Bottom overlay: stream info + reply */} -
-
-
- _this is a placeholder for captions_ -
- - -
-
- - {/* Text compose overlay */} - {composingText && streamId && ( - setComposingText(false)} - /> - )} - - {/* Recording overlay */} - {showRecordingOverlay && ( - - )} -
- ); -} diff --git a/js/src/stores/playback-store.ts b/js/src/stores/playback-store.ts index d199e4f..70df166 100644 --- a/js/src/stores/playback-store.ts +++ b/js/src/stores/playback-store.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; -import type { StreamParticle } from "@/api/types"; +import type { Particle } from "@/api/types"; type PlaybackStatus = "idle" | "playing" | "ended"; interface PlaybackState { streamId: string | null; - particles: StreamParticle[]; + particles: Particle[]; currentIndex: number; status: PlaybackStatus; paused: boolean; @@ -13,7 +13,7 @@ interface PlaybackState { initStream: ( streamId: string, - particles: StreamParticle[], + particles: Particle[], startIndex: number, ) => void; next: () => void; -- 2.54.0 From d316ba1e09790bffd5a18434859f8ad9eb7248a0 Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 18 Mar 2026 14:52:22 -0700 Subject: [PATCH 05/39] introduce stream compose flow --- js/src/App.tsx | 2 +- js/src/features/compose/compose-overlay.tsx | 49 +++++ .../compose/configure-stream-step.tsx | 196 ++++++++++++++++++ .../{send => compose}/controls-indicator.tsx | 8 +- .../{send => compose}/recording-overlay.tsx | 71 +++---- .../text-compose-step.tsx} | 60 ++---- .../features/compose/use-compose-keyboard.ts | 96 +++++++++ js/src/features/compose/use-recorder.ts | 109 ++++++++++ js/src/features/layoutwithpath.tsx | 9 +- js/src/features/network-root.tsx | 4 +- .../features/particles/particle-list-view.tsx | 2 +- js/src/features/send/use-recorder.ts | 190 ----------------- js/src/stores/compose-store.ts | 106 ++++++++++ js/src/stores/recording-store.ts | 54 ----- 14 files changed, 616 insertions(+), 340 deletions(-) create mode 100644 js/src/features/compose/compose-overlay.tsx create mode 100644 js/src/features/compose/configure-stream-step.tsx rename js/src/features/{send => compose}/controls-indicator.tsx (84%) rename js/src/features/{send => compose}/recording-overlay.tsx (74%) rename js/src/features/{send/text-compose-overlay.tsx => compose/text-compose-step.tsx} (56%) create mode 100644 js/src/features/compose/use-compose-keyboard.ts create mode 100644 js/src/features/compose/use-recorder.ts delete mode 100644 js/src/features/send/use-recorder.ts create mode 100644 js/src/stores/compose-store.ts delete mode 100644 js/src/stores/recording-store.ts 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 (