feat: initial conversational flow (#37)

* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
Arjun Patel
2026-03-19 16:29:40 -07:00
committed by GitHub
parent 990137b829
commit 4b66d8e185
48 changed files with 2113 additions and 1483 deletions
+3 -29
View File
@@ -447,7 +447,7 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// DownloadParticleMedia redirects to a fresh signed download URL for media/file particles
// DownloadParticleMedia returns a fresh signed download URL for media/file particles
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
_, ok := middleware.EmailFromContext(r.Context())
if !ok {
@@ -459,33 +459,6 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
objectID := r.PathValue("id")
// particleID := r.PathValue("id")
// if particleID == "" {
// http.Error(w, "object id is required", http.StatusBadRequest)
// return
// }
// p, err := h.particleSvc.GetByID(r.Context(), particleID, email)
// if err != nil {
// if errors.Is(err, particle.ErrNotFound) {
// http.Error(w, "particle not found", http.StatusNotFound)
// return
// }
// if errors.Is(err, particle.ErrAccessDenied) {
// http.Error(w, "access denied", http.StatusForbidden)
// return
// }
// slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email)
// http.Error(w, "internal server error", http.StatusInternalServerError)
// return
// }
//
// objectID := extractObjectID(p)
// if objectID == "" {
// http.Error(w, "particle has no downloadable content", http.StatusBadRequest)
// return
// }
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
if err != nil {
slog.Error("failed to get download URL", "error", err, "object_id", objectID)
@@ -493,7 +466,8 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
return
}
http.Redirect(w, r, downloadURL, http.StatusFound)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": downloadURL})
}
// ============================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+1
View File
@@ -10,6 +10,7 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses';
const config: ForgeConfig = {
packagerConfig: {
asar: true,
icon: './assets/flowy',
},
rebuildConfig: {},
makers: [
+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "js",
"productName": "js",
"name": "llink",
"productName": "llink",
"version": "1.0.0",
"description": "My Electron application description",
"description": "Flowy.llink is a team communication app for teams",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
+14 -5
View File
@@ -8,8 +8,11 @@ import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import PathResolver from "./pages/path-resolver";
import { SettingsPage } from "./pages/settings-page";
import SettingsPage from "@/features/settings-page";
import NetworkSelector from "@/features/network-selector";
import NetworkRoot from "@/features/network-root";
import ParticleViewResolver from "@/features/particles/particle-view-resolver";
import Layout from "@/features/layout";
const queryClient = new QueryClient();
@@ -37,12 +40,18 @@ const App = () => {
};
function AuthenticatedApp() {
// NOTE: Hash router provides history, despite using catch-all
return (
<HashRouter>
<Routes>
<Route path="/settings" element={<SettingsPage />} />
<Route path="/*" element={<PathResolver />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="/" element={<Layout />}>
<Route index element={<NetworkSelector />} />
<Route path=":networkId">
<Route index element={<NetworkRoot />} />
<Route path="*" element={<ParticleViewResolver />} />
</Route>
</Route>
</Routes>
</HashRouter>
);
+7 -4
View File
@@ -44,9 +44,11 @@ class ApiClient {
path: string,
body?: unknown,
): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const headers: Record<string, string> = {};
if (body) {
headers["Content-Type"] = "application/json";
}
const token = this.config.getToken();
if (token) {
@@ -115,7 +117,8 @@ class ApiClient {
"GET",
`/particles/${objectId}/download`,
);
return response.url;
const data = await response.json();
return data.url;
}
// --- Depot ---
+17 -5
View File
@@ -128,14 +128,26 @@ 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", [
ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
ParticleBaseSchema.extend({
type: z.literal("stream"), properties: StreamPropertiesSchema,
// 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()),
// Marks human_id to their `playback_position_at`: where they left off in a conversation
playback_markers: z.record(z.string(), z.coerce.date()).optional(),
// Timestamp of the most recent child particle
// used for sorting streams by recent activity without needing to query subcollections
last_child_created_at: z.coerce.date().optional(),
}),
ParticleBaseSchema.extend({
type: z.literal("folder"), properties: FolderPropertiesSchema,
// 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()),
}),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
+31
View File
@@ -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<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { useRecorder } from "@/features/compose/use-recorder";
import { particlePath } from "@/lib/particle-path";
import type { ParticlePath } 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 { apiClient } from "@/api/client";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring";
interface ComposeOverlayProps {
networkId: string;
// Optional target path for reply mode. If not provided, compose creates a new stream.
targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void;
}
/**
* Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply).
*/
export function ComposeOverlay({
networkId,
targetPath,
onActiveChange,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle");
const [error, setError] = useState<string | null>(null);
const [textContent, setTextContent] = useState("");
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewDurationMs, setReviewDurationMs] = useState(0);
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const userEmail = useAuthStore((s) => s.user?.email);
const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle();
// Refs to avoid stale closures in keyboard handler
const stepRef = useRef(step);
stepRef.current = step;
// Notify parent when active state changes
useEffect(() => {
onActiveChange?.(step !== "idle");
}, [step, onActiveChange]);
const cancel = useCallback(() => {
setStep("idle");
setError(null);
setTextContent("");
setMediaStream(null);
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
}, []);
const { startRecording, stopRecording, cancelRecording } = useRecorder({
mode: recordingMode,
onStreamReady: (stream) => setMediaStream(stream),
onStreamCleanup: () => setMediaStream(null),
onFinish: (blob, durationMs, mimeType) => {
setStep("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => setError(message),
});
// --- Submission ---
const uploadMedia = useCallback(
async (blob: Blob, mimeType: string) => {
const ext = "webm";
const fileName = `recording-${Date.now()}.${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: blob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: upload_headers,
body: blob,
});
await apiClient.confirmUpload(object_id);
return { object_id, size_bytes: blob.size };
},
[networkId],
);
const createChildParticle = useCallback(
async (path: ParticlePath) => {
if (!userEmail) return;
if (textContent.trim()) {
await createParticle.mutateAsync({
path,
type: "text",
properties: { content: textContent },
createdByEmail: userEmail,
});
} else if (reviewBlob && reviewMimeType) {
const { object_id, size_bytes } = await uploadMedia(
reviewBlob,
reviewMimeType,
);
await createParticle.mutateAsync({
path,
type: "media",
properties: {
object_id,
mime_type: reviewMimeType,
duration_ms: reviewDurationMs,
size_bytes,
},
createdByEmail: userEmail,
});
}
},
[
userEmail,
textContent,
reviewBlob,
reviewMimeType,
reviewDurationMs,
createParticle,
uploadMedia,
],
);
// Reply mode: create particle directly under targetPath
const onSubmitReply = useEffectEvent(async () => {
if (!targetPath || !userEmail) return;
await createChildParticle(targetPath);
cancel();
});
// New stream mode: create stream + first child
const handleStreamSubmit = useCallback(
async (streamName: string, visibleTo: string[]) => {
if (!userEmail) return;
const streamId = await createStream.mutateAsync({
networkId,
properties: {
name: streamName,
status: "open",
},
createdByEmail: userEmail,
visibleTo,
});
const streamChildrenPath = particlePath(networkId, [streamId]);
await createChildParticle(streamChildrenPath);
cancel();
},
[networkId, userEmail, createParticle, createChildParticle, cancel],
);
// --- Keyboard handling ---
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring") return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (currentStep) {
case "idle": {
if (e.key === "`" && !e.repeat) {
e.preventDefault();
setStep("recording");
startRecording();
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStep("typing");
}
break;
}
case "recording": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
}
break;
}
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
if (targetPath) {
onSubmitReply();
} else {
setStep("configuring");
}
}
break;
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`") {
e.preventDefault();
stopRecording();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel]);
// --- Render ---
if (step === "idle") return null;
const handleTextAdvance = targetPath
? onSubmitReply
: () => setStep("configuring");
return (
<>
{(step === "recording" || step === "reviewing") && (
<RecordingOverlay
step={step}
mediaStream={mediaStream}
recordingMode={recordingMode}
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
/>
)}
{step === "typing" && (
<TextComposeStep
textContent={textContent}
onTextChange={setTextContent}
onAdvance={handleTextAdvance}
onCancel={cancel}
/>
)}
{!targetPath && step === "configuring" && (
<ConfigureStreamStep
networkId={networkId}
onCancel={cancel}
onSubmit={handleStreamSubmit}
/>
)}
</>
);
}
@@ -0,0 +1,168 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNetworks } from "@/hooks/use-networks";
import { cn } from "@/lib/utils";
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(() => generateRandomName());
const [everyone, setEveryone] = useState(true);
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
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 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;
onSubmit(name.trim(), buildVisibleTo());
}, [name, networkId, onSubmit, buildVisibleTo]);
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();
}
return;
}
},
[onCancel, handleSubmit, members, name, toggleMember],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
{/* Stream name */}
<div>
<Label className="mb-1 text-xs text-white/50">Stream name</Label>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
}}
placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
{/* Everyone in network */}
<div
role="button"
onClick={() => 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",
)}
>
<Checkbox
checked={everyone}
onCheckedChange={(checked) => setEveryone(checked === true)}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="font-medium">Everyone in network</span>
</div>
{/* Per-member selection */}
{!everyone && members.length > 0 && (
<ScrollArea className="max-h-48">
<div className="space-y-0.5 p-1">
{members.map((member, index) => {
const isSelected = selectedEmails.has(member.email);
const initials = member.email_prefix
.slice(0, 2)
.toUpperCase();
return (
<div
key={member.email}
role="button"
onClick={() => 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",
)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
{member.email_prefix}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
{/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
cancel
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
+Enter
</kbd>{" "}
create
</span>
</div>
</div >
);
}
@@ -0,0 +1,69 @@
import { Video, Mic } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMediaSettingsStore } from "@/stores/media-settings-store";
import { Button } from "@/components/ui/button";
import { PropsWithChildren } from "react";
interface ControlsIndicatorProps extends PropsWithChildren {
type: "reply" | "new";
}
export default function ControlsIndicator({ type, children }: ControlsIndicatorProps) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
return (
<div className="flex items-center gap-2 text-xs rounded-full pl-1 pr-3 py-1 bg-black/30 backdrop-blur-sm m-2">
<Button
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
variant="secondary"
className={cn(
"text-xs rounded-full",
"text-muted-foreground hover:text-white/90",
)}
>
{recordingMode === "video" ? (
<>
<Video className="h-3.5 w-3.5" />
<p>Video</p>
</>
) : (
<>
<Mic className="h-3.5 w-3.5" />
<span>Audio</span>
</>
)}
</Button>
{children ? <div className="flex-1">{children}</div> :
<div className="flex-1" />
}
{children && <span className="text-muted-foreground">·</span>}
<div className="text-muted-foreground">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
`
</kbd>{" "}
to {type === "reply" ? "reply " : "start "}
· Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono",
)}
>
T
</kbd>{" "}
for text
</div>
</div>
);
}
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useRecordingStore } from "@/stores/recording-store";
import type { RecordingMode } from "@/hooks/use-recording-mode";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
interface RecordingOverlayProps {
step: "recording" | "reviewing";
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<HTMLVideoElement>(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 && <div className="absolute inset-0 bg-black/60" />}
{/* Top center: recording indicator / uploading */}
{/* Top center: recording indicator */}
<div className="absolute top-8 z-10">
{isUploading ? (
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-500" />
<span className="text-sm text-white/80">Sending...</span>
</div>
) : isRecording && !isLoading ? (
{isRecording && !isLoading ? (
<RecordingTimer />
) : isReviewing ? (
<div className="flex items-center gap-2">
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
) : null}
</div>
{/* Center: audio level bars (recording with active stream) */}
{/* Bottom center: audio level bars (recording with active stream) */}
{isRecording && recordingAudioSource && (
<div className="z-10">
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
</div>
)}
@@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Enter
</kbd>{" "}
to send
next
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
@@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
)}
{/* Error state */}
{status === "error" && (
{error && (
<div className="z-10 text-sm text-red-400">
{useRecordingStore.getState().error ?? "Recording failed"}
{error}
</div>
)}
</div>
@@ -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<HTMLTextAreaElement>(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 (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={sending}
className={cn(
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
style.size,
@@ -86,9 +66,9 @@ export function TextComposeOverlay({
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Cmd+Enter
+Enter
</kbd>{" "}
send
next
</span>
</div>
</div>
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef } from "react";
import type { RecordingMode } from "@/hooks/use-recording-mode";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME)
? AUDIO_PREFERRED_MIME
: AUDIO_FALLBACK_MIME;
}
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
interface UseRecorderOptions {
mode: RecordingMode;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
/**
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* about application state. The consumer provides callbacks for all outputs.
*/
export function useRecorder({
mode,
onStreamReady,
onStreamCleanup,
onFinish,
onError,
}: UseRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
// 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 = [];
onStreamCleanupRef.current();
}, []);
const startRecording = useCallback(async () => {
try {
const constraints =
mode === "video" ? { video: true, audio: true } : { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
onStreamReady(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(mode);
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopTracks();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start();
} catch (err) {
stopTracks();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [mode, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
}, [stopTracks]);
useEffect(() => {
return () => stopTracks();
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording };
}
@@ -1,11 +1,7 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Home, Settings } from "lucide-react";
import { NetworkSelector } from "@/features/network-selector";
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 { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { Home, Settings } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
@@ -15,11 +11,27 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useAuthStore } from "@/stores/auth-store";
import { useNetworks } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { useParticle } from "@/hooks/use-particle";
import type { Particle } from "@/api/types";
function parsePathSegments(path: string): string[] {
return path.split("/").filter(Boolean);
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case "stream":
case "folder":
return particle.properties.name;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
case "file":
return particle.properties.filename;
case "text":
return particle.properties.content.slice(0, 30);
case "media":
return particle.type;
}
}
function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
@@ -40,22 +52,25 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
);
}
function TopBar({ segments }: { segments: string[] }) {
function TopBar() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const networkId = segments[0] ?? null;
const { networkId, "*": rest } = useParams();
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean);
const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : undefined;
const { data: particle } = useParticle(path);
return (
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Breadcrumb className="no-drag">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbItem className="text-xs">
{segments.length === 0 ? (
<BreadcrumbPage className="flex items-center gap-1">
<Home className="size-3.5" />
Networks
</BreadcrumbPage>
) : (
<BreadcrumbLink
@@ -63,7 +78,6 @@ function TopBar({ segments }: { segments: string[] }) {
onClick={() => navigate("/")}
>
<Home className="size-3.5" />
Networks
</BreadcrumbLink>
)}
</BreadcrumbItem>
@@ -71,7 +85,7 @@ function TopBar({ segments }: { segments: string[] }) {
{networkId && (
<span className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbItem className="text-xs">
{segments.length === 1 ? (
<BreadcrumbPage>
<NetworkBreadcrumbContent networkId={networkId} />
@@ -88,33 +102,19 @@ function TopBar({ segments }: { segments: string[] }) {
</span>
)}
{segments.slice(1).map((segment, index) => {
const isLast = index === segments.length - 2;
const path = `/${segments.slice(0, index + 2).join("/")}`;
return (
<span key={path} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{segment}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer"
onClick={() => navigate(path)}
>
{segment}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
);
})}
{particle && (
<span key={path} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage>
</BreadcrumbItem>
</span>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="flex-1" />
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
<Button
variant="ghost"
size="sm"
@@ -127,41 +127,11 @@ function TopBar({ segments }: { segments: string[] }) {
);
}
/**
* URL structure:
* / network selector
* /:networkId root particles for that network
* /:networkId/:p1/:p2/... nested particle view (renders the parent which will use it's children)
*/
export default function PathResolver() {
const segments = parsePathSegments(useLocation().pathname);
const content = (() => {
// No segments → show network selector
if (segments.length === 0) {
return <NetworkSelector />;
}
const [networkId, ...particleSegments] = segments;
// /:networkId with no particle segments → root particle list
if (particleSegments.length === 0) {
return <ParticleListView networkId={networkId} particleSegments={[]} />;
}
// /:networkId/:p1/:p2/... → resolve and render the container particle
return (
<ParticleViewResolver
networkId={networkId}
particleSegments={particleSegments}
/>
);
})();
export default function Layout() {
return (
<div className="flex h-screen flex-col">
<TopBar segments={segments} />
<div className="flex-1 overflow-hidden">{content}</div>
<TopBar />
<Outlet />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useParams } from "react-router-dom";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { ComposeOverlay } from "./compose/compose-overlay";
/**
* Route-level component for /:networkId (index).
* Shows root-level particles for the selected network.
*/
export default function NetworkRoot() {
const { networkId } = useParams();
const path = particlePath(networkId!, []);
return (
<div className="flex flex-col h-full relative">
<ParticleListView path={path} />
<ComposeOverlay networkId={networkId!} />
<ControlsIndicator type={"new"} />
</div>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ function NetworkRow({
);
}
export function NetworkSelector() {
export default function NetworkSelector() {
const navigate = useNavigate();
const { data, isPending, error } = useNetworks();
+9 -6
View File
@@ -1,20 +1,23 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
interface FolderViewProps {
folderParticle: Particle;
networkId: string;
particleSegments: string[];
path: ParticlePath;
}
export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {networkId}/{particleSegments.join("/")}
Folder view {folderParticle.id}
</p>
<ComposeOverlay networkId={networkId} />
</div>
);
}
+244 -30
View File
@@ -1,43 +1,257 @@
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Radio,
MessageSquare,
Video,
Mic,
Image,
FileText,
CircleCheck,
StickyNote,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import {
parseParticlePath,
particlePath,
type ParticlePath,
} from "@/lib/particle-path";
import { getInitials } from "@/lib/utils";
import { formatDistanceToNow } from "@/lib/time-utils";
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 type { Particle, StreamProperties } from "@/api/types";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
case "text":
return MessageSquare;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return Video;
if (mime.startsWith("audio/")) return Mic;
if (mime.startsWith("image/")) return Image;
return Video;
}
case "file":
return FileText;
case "quest":
return CircleCheck;
case "paper":
return StickyNote;
default:
return Radio;
}
}
function getMessagePreview(particle: Particle): string {
switch (particle.type) {
case "text":
return particle.properties.content;
case "media": {
const mime = particle.properties.mime_type;
if (mime.startsWith("video/")) return "Video clip";
if (mime.startsWith("audio/")) return "Voice note";
if (mime.startsWith("image/")) return "Photo";
return "Media";
}
case "file":
return particle.properties.filename;
case "quest":
return particle.properties.title;
case "paper":
return particle.properties.title;
default:
return particle.type;
}
}
function StreamRow({
particle,
networkId,
onClick,
}: {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onClick: () => void;
}) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const userEmail = user?.email ?? "";
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userEmail}`,
);
if (otherEntry) {
const otherEmail = otherEntry.replace("human:", "");
return getInitials(otherEmail);
}
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userEmail]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_email === userEmail;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const emailPrefix = latestChild.created_by_email.split("@")[0];
const capitalized =
emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1);
return `${capitalized}: `;
}, [latestChild, userEmail, isDM]);
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent"
>
<Avatar
className={cn(isUnseen && "ring-2 ring-primary")}
>
<AvatarFallback className="bg-primary/10 text-primary font-medium">
{initials}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"truncate text-sm",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</p>
{latestChild && (
<Small
className={cn(
"shrink-0",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
{formatDistanceToNow(latestChild.created_at.toISOString())}
</Small>
)}
</div>
<div className="flex items-center gap-1">
<TypeIcon
className={cn(
"size-3.5 shrink-0",
isUnseen ? "text-foreground" : "text-muted-foreground",
)}
/>
<Small
className={cn(
"truncate",
isUnseen
? "text-foreground font-medium"
: "text-muted-foreground font-normal",
)}
>
{senderPrefix && (
<span className="text-muted-foreground">{senderPrefix}</span>
)}
{subtitle}
</Small>
</div>
</div>
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</button>
);
}
// Generates the scopes for filtering particles to those that the user has access to
function useVisibilityScopes(
userEmail?: string,
networkId?: string,
) {
return useMemo(() => {
let scopes: string[] = [];
if (userEmail) {
scopes.push(`human:${userEmail}`);
}
if (networkId) {
scopes.push(`network:${networkId}`);
}
return scopes;
}, [userEmail, networkId]);
}
interface ParticleListViewProps {
networkId: string;
particleSegments: string[];
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({ networkId, particleSegments }: ParticleListViewProps) {
const { children, isLoading } = useParticleChildren(networkId, particleSegments);
export function ParticleListView({ path }: ParticleListViewProps) {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.email, networkId);
const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes);
const navigate = useNavigate();
const streams = useMemo(
() => children.filter((c) => c.type === "stream"),
[children],
);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading particles...</p>
</div>
);
}
if (children.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">No particles yet</p>
</div>
);
return <Progress />;
}
return (
<div className="grid grid-cols-2 gap-3 p-4">
{children.map((child) => (
<div
key={child.id}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">{child.id}</p>
<p className="text-muted-foreground text-xs">{child.type}</p>
</div>
))}
</div>
<ScrollArea className="h-full">
<div className="py-1">
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
</ScrollArea>
);
}
@@ -1,6 +1,5 @@
import { useEffect, useState } from "react";
import type { StreamParticle } from "@/api/types";
import { getParticleData } from "@/api/types";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -11,21 +10,8 @@ import {
FileIcon,
FolderIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface ParticlePreviewProps {
particle: StreamParticle;
}
/** Dynamic text sizing for card previews — inspired by TextParticleView. */
function getPreviewTextStyle(length: number) {
if (length < 30) return "text-xl font-semibold";
if (length < 80) return "text-lg font-medium";
if (length < 200) return "text-base font-normal";
return "text-sm font-normal";
}
export function ParticlePreview({ particle }: ParticlePreviewProps) {
export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) {
case "text":
return <TextPreview particle={particle} />;
@@ -44,27 +30,24 @@ export function ParticlePreview({ particle }: ParticlePreviewProps) {
}
}
function TextPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "text");
// only show x first chars for preview, to avoid overflow and also to determine text size
const truncated = data.content.length > 30 ? data.content.slice(0, 30) + "..." : data.content;
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
const truncated =
particle.properties.content.length > 30
? particle.properties.content.slice(0, 30) + "..."
: particle.properties.content;
return (
<div className="flex h-full w-full items-center justify-center p-4">
<p
className={cn(
"line-clamp-4 text-center leading-relaxed text-4xl",
)}
>
<p className="line-clamp-4 text-center text-4xl leading-relaxed">
{truncated}
</p>
</div>
);
}
function MediaPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "media");
const isVideo = data.mime_type.startsWith("video");
const durationSec = Math.round(data.duration_ms / 1000);
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
const { mime_type, duration_ms } = particle.properties;
const isVideo = mime_type.startsWith("video");
const durationSec = Math.round(duration_ms / 1000);
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
if (isVideo) {
@@ -132,54 +115,51 @@ function VideoThumbnail({
);
}
function QuestPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "quest");
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
const { title, status } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
<ScrollText className="h-6 w-6 text-amber-600/70 dark:text-amber-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{data.title}
{title}
</p>
{data.status && (
{status && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{data.status}
{status}
</span>
)}
</div>
);
}
function PaperPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "paper");
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
<BookOpen className="h-6 w-6 text-blue-600/70 dark:text-blue-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">
{data.title}
{particle.properties.title}
</p>
</div>
);
}
function FilePreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "file");
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
<FileIcon className="h-6 w-6 text-emerald-600/70 dark:text-emerald-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{data.filename}
{particle.properties.filename}
</p>
</div>
);
}
function FolderPreview({ particle }: { particle: StreamParticle }) {
const data = getParticleData(particle, "folder");
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
<FolderIcon className="h-6 w-6 text-orange-600/70 dark:text-orange-400/70" />
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
{data.name}
{particle.properties.name}
</p>
</div>
);
@@ -1,20 +1,22 @@
import { useParticle } from "@/hooks/use-particle";
import { StreamView } from "./stream-view";
import { FolderView } from "./folder-view";
import { ParticleListView } from "./particle-list-view";
import { useParams } from "react-router-dom";
import { useLiveParticle } from "@/hooks/use-particle";
import { particlePath } from "@/lib/particle-path";
import { isContainerType } from "@/api/types";
interface ParticleViewResolverProps {
networkId: string;
particleSegments: string[];
}
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
import { ParticleListView } from "@/features/particles/particle-list-view";
/**
* Resolves a particle by its path segments and renders the appropriate view
* based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
* Route-level component for /:networkId/*.
* Reads params from the router, resolves the particle, and renders
* the appropriate view based on particle type.
*/
export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
const { particle, isLoading, error } = useParticle(networkId, particleSegments);
export default function ParticleViewResolver() {
const { networkId, "*": rest } = useParams();
const segments = (rest ?? "").split("/").filter(Boolean);
const path = particlePath(networkId!, segments); // path of current container particle
const { particle, isLoading, error } = useLiveParticle(path);
if (isLoading) {
return (
@@ -32,12 +34,11 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
);
}
// While the hook is stubbed, particle will be null — show a placeholder
if (!particle) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Particle: {particleSegments.join(" / ")}
Particle: {segments.join(" / ")}
</p>
</div>
);
@@ -45,15 +46,13 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <StreamView streamParticle={particle} path={path} />;
case "folder":
return <FolderView folderParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
return <FolderView folderParticle={particle} path={path} />;
default:
// For container types we haven't built a view for, fall back to list
if (isContainerType(particle.type)) {
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
return <ParticleListView path={path} />;
}
// Leaf particle — placeholder
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
+421 -25
View File
@@ -1,36 +1,432 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import type { Particle } from "@/api/types";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { MediaParticleView } from "@/features/playback/media-particle-view";
import { TextParticleView } from "@/features/playback/text-particle-view";
import { FallbackParticleView } from "@/features/playback/fallback-particle-view";
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork } from "@/hooks/use-networks";
interface StreamViewProps {
streamParticle: Particle;
networkId: string;
particleSegments: string[];
// --- Playback reducer ---
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
}
export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
type PlaybackAction =
| { type: "INIT"; particleCount: number, initialIndex?: number }
| { type: "NEXT"; particleCount: number }
| { type: "PREV" }
| { type: "GO_TO"; index: number; particleCount: number }
| { type: "PAUSE" }
| { type: "RESUME" }
| { type: "SYNC_PARTICLES"; particleCount: number };
function playbackReducer(
state: PlaybackState,
action: PlaybackAction,
): PlaybackState {
switch (action.type) {
case "INIT":
return {
currentIndex: action.initialIndex ?? 0,
status: action.particleCount > 0 ? "playing" : "idle",
paused: false,
};
case "NEXT":
if (state.currentIndex < action.particleCount - 1) {
return { ...state, currentIndex: state.currentIndex + 1, paused: false };
}
return { ...state, status: "ended", paused: false };
case "PREV":
if (state.currentIndex > 0) {
return {
...state,
currentIndex: state.currentIndex - 1,
status: "playing",
paused: false,
};
}
return state;
case "GO_TO":
if (action.index >= 0 && action.index < action.particleCount) {
return {
...state,
currentIndex: action.index,
status: "playing",
paused: false,
};
}
return state;
case "PAUSE":
return { ...state, paused: true };
case "RESUME":
return { ...state, paused: false };
case "SYNC_PARTICLES":
// Clamp index if particles were removed; don't reset position
if (action.particleCount === 0) {
return { currentIndex: 0, status: "idle", paused: state.paused };
}
if (state.status === "ended" && state.currentIndex < action.particleCount - 1) {
// New particle appended — resume and advance to it
return { ...state, currentIndex: state.currentIndex + 1, status: "playing", paused: false };
}
if (state.currentIndex >= action.particleCount) {
return { ...state, currentIndex: action.particleCount - 1 };
}
return state;
}
}
const initialState: PlaybackState = {
currentIndex: 0,
status: "idle",
paused: false,
};
// --- Exit countdown hook ---
const EXIT_DELAY_MS = 5000;
const EXIT_TICK_MS = 100;
function useExitCountdown(
status: PlaybackStatus,
composeActive: boolean,
onExit: () => void,
) {
const [remainingMs, setRemainingMs] = useState<number | null>(null);
const handleExit = useEffectEvent(() => {
onExit();
});
// Start/cancel countdown based on playback status
useEffect(() => {
if (status === "ended") {
setRemainingMs(EXIT_DELAY_MS);
} else {
setRemainingMs(null);
}
}, [status]);
// Tick the countdown down (pauses when compose is active)
useEffect(() => {
if (remainingMs === null || remainingMs <= 0 || composeActive) return;
const interval = setInterval(() => {
setRemainingMs((prev) => {
if (prev === null) return null;
const next = prev - EXIT_TICK_MS;
return next <= 0 ? 0 : next;
});
}, EXIT_TICK_MS);
return () => clearInterval(interval);
}, [remainingMs !== null && remainingMs > 0, composeActive]);
// Navigate once countdown hits zero
useEffect(() => {
if (remainingMs !== null && remainingMs <= 0) {
handleExit();
}
}, [remainingMs]);
return remainingMs;
}
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const navigate = useNavigate();
const { children } = useLiveParticleChildren(path, "created_at", "asc");
const [state, dispatch] = useReducer(playbackReducer, initialState);
const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0);
const hasInitializedRef = useRef<string | null>(null);
const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
const exitRemainingMs = useExitCountdown(
state.status,
composeActive,
handleExitNavigate,
);
const userId = useAuthStore((s) => s.user?.id);
// Reset progress when particle changes
useEffect(() => {
setProgress(0);
}, [state.currentIndex]);
// Init playback once per stream entry, only after children have loaded
useEffect(() => {
if (children.length === 0) return;
if (hasInitializedRef.current === streamParticle.id) return;
hasInitializedRef.current = streamParticle.id;
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
let initialIndex = 0;
if (playbackPosition) {
const foundIndex = children.findIndex(
(c) => c.created_at.getTime() === playbackPosition.getTime(),
);
if (foundIndex !== -1) {
initialIndex = foundIndex;
}
}
dispatch({ type: "INIT", particleCount: children.length, initialIndex });
}, [streamParticle.id, userId, children]);
// Sync on subsequent changes (new particle appended, removed, etc.)
useEffect(() => {
if (hasInitializedRef.current !== streamParticle.id) return;
dispatch({ type: "SYNC_PARTICLES", particleCount: children.length });
}, [children.length, streamParticle.id]);
// Pause/resume playback when compose overlay opens/closes
useEffect(() => {
if (composeActive) dispatch({ type: "PAUSE" });
else dispatch({ type: "RESUME" });
}, [composeActive]);
const next = useCallback(() => {
dispatch({ type: "NEXT", particleCount: children.length });
}, [children.length]);
const prev = useCallback(() => {
dispatch({ type: "PREV" });
}, []);
const goTo = useCallback(
(index: number) => {
dispatch({ type: "GO_TO", index, particleCount: children.length });
},
[children.length],
);
// Click-to-navigate: left 30% = prev, right 70% = next
const handlePlaybackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) prev();
else if (x > 0.7) next();
},
[prev, next],
);
// Playback keyboard: arrows, escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
next();
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
prev();
break;
case "Escape":
e.preventDefault();
navigate(`/${networkId}`);
break;
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
},
[composeActive, next, prev, navigate, networkId],
);
const currentParticle = children[state.currentIndex] ?? null;
useEffect(() => {
if (!userId || !currentParticle) return;
const streamDocPath = toFirestoreDocPath(path);
updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at);
}, [currentParticle?.id, path])
// Author info from current particle
const authorEmail = currentParticle?.created_by_email ?? "";
const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? "";
if (children.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<ControlsIndicator type="reply" />
<ComposeOverlay
networkId={networkId!}
targetPath={path}
onActiveChange={setComposeActive}
/>
</div>
);
}
// Render particle content inline (replaces ParticleRenderer)
function renderParticle(particle: Particle) {
switch (particle.type) {
case "media":
return (
<MediaParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
paused={state.paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} />;
}
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Stream view {networkId}/{particleSegments.join("/")}
</p>
<div className="relative flex h-full flex-col bg-black text-white">
{/* Progress indicator */}
<div className="z-10 absolute left-0 right-0">
<PlaybackPageIndicator
total={children.length}
current={state.currentIndex}
progress={progress}
onGoTo={goTo}
/>
</div>
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
{error && <p className="text-destructive text-sm">Failed to load stream data</p>}
{!isLoading && !error && (
<div className="mt-4">
<p className="text-sm font-medium">Stream Children:</p>
<ul className="list-disc list-inside">
{children.map((child) => (
<li key={child.id} className="text-sm">
{child.id} ({child.type})
</li>
))}
</ul>
{/* Author overlay */}
{currentParticle && (
<div className="absolute top-5 left-1/2 transform -translate-x-1/2 z-10 flex items-center justify-center gap-2 bg-black/30 backdrop-blur-sm p-1 pr-2 rounded-full">
<Avatar size="sm">
<AvatarFallback className="bg-white/20 text-[10px] font-medium text-white">
{authorInitials}
</AvatarFallback>
</Avatar>
<span className="text-xs text-white/70">
{authorEmail.split("@")[0]}
</span>
</div>
)}
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)}
</div>
)}
</div>
<ComposeOverlay
networkId={networkId!}
targetPath={path}
onActiveChange={setComposeActive}
/>
{/* Bottom overlay: stream info + reply */}
<div className="absolute right-0 left-0 bottom-0 z-10">
<ControlsIndicator type={"reply"}>
<div className="flex items-center gap-1 text-xs text-white/70">
Seen by
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
{/* Exit countdown */}
{exitRemainingMs !== null && (
<span>
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
)}
</div>
</ControlsIndicator>
</div>
</div>
);
}
// Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle.
const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => {
const network = useNetwork(networkId);
const playbackMarkers = stream.playback_markers ?? {};
const seenUserIds = Object.entries(playbackMarkers)
.filter(([_, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime())
.map(([userId, _]) => userId);
const seenUserEmails = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId)?.email)
.filter((email): email is string => !!email);
if (seenUserIds.length === 0) return null;
return (
<AvatarGroup>
{seenUserEmails.map((email) => (
<Tooltip key={email}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback>
{email.split("@")[0].slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>Seen by {email.split("@")[0]}</p>
</TooltipContent>
</Tooltip>
))}
</AvatarGroup>
);
}
-84
View File
@@ -1,84 +0,0 @@
import { Heart } from "lucide-react";
import { useCallback } from "react";
import type { AckInfo } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAuthStore } from "@/stores/auth-store";
import { useAppStore } from "@/stores/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AckButtonProps {
particleId: string;
acks: AckInfo[];
}
function getInitials(email: string): string {
const prefix = email.split("@")[0];
const parts = prefix.split(/[._-]/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return prefix.slice(0, 2).toUpperCase();
}
export function AckButton({ particleId, acks }: AckButtonProps) {
const currentEmail = useAuthStore((s) => s.user?.email);
const ackParticle = useAppStore((s) => s.ackParticle);
const hasAcked = acks.some((a) => a.email === currentEmail);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (hasAcked || !currentEmail) return;
ackParticle(particleId, currentEmail);
apiClient.ackParticle(particleId).catch(() => {});
},
[hasAcked, currentEmail, particleId, ackParticle],
);
const displayedAcks = acks.slice(0, 3);
return (
<div className="flex flex-col items-center gap-1.5">
<button
type="button"
onClick={handleClick}
className={cn(
"flex h-10 w-10 flex-col items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-colors",
hasAcked
? "text-red-500"
: "text-white hover:bg-black/40",
)}
>
<Heart
className="h-4 w-4"
fill={hasAcked ? "currentColor" : "none"}
/>
<span className="mt-0.5 text-[10px] font-medium leading-none">
{acks.length}
</span>
</button>
{displayedAcks.length > 0 && (
<div className="flex flex-col items-center gap-1">
{displayedAcks.map((ack) => (
<Tooltip key={ack.email}>
<TooltipTrigger asChild>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white backdrop-blur-sm">
{getInitials(ack.email)}
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{ack.email}</p>
</TooltipContent>
</Tooltip>
))}
</div>
)}
</div>
);
}
@@ -1,5 +1,4 @@
import type { StreamParticle } from "@/api/types";
import { getParticleData } from "@/api/types";
import type { Particle } from "@/api/types";
import {
Card,
CardContent,
@@ -16,7 +15,7 @@ const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
};
interface FallbackParticleViewProps {
particle: StreamParticle;
particle: Particle;
}
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
@@ -28,13 +27,13 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
const title = (() => {
switch (particle.type) {
case "quest":
return getParticleData(particle, "quest").title;
return particle.properties.title;
case "paper":
return getParticleData(particle, "paper").title;
return particle.properties.title;
case "file":
return getParticleData(particle, "file").filename;
return particle.properties.filename;
case "folder":
return getParticleData(particle, "folder").name;
return particle.properties.name;
default:
return null;
}
@@ -1,86 +1,37 @@
import { useEffect, useRef, useState } from "react";
import type { MediaParticleData, StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { usePlaybackStore } from "@/stores/playback-store";
import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
type MediaParticle = Extract<Particle, { type: "media" }>;
interface MediaParticleViewProps {
particle: StreamParticle;
}
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function DurationPill({
currentTimeMs,
totalDurationMs,
}: {
currentTimeMs: number;
totalDurationMs: number;
}) {
return (
<div className="absolute top-3 right-3 rounded-full bg-white/10 px-2.5 py-1 backdrop-blur-sm">
<span className="font-mono text-xs text-white/80">
{formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
</span>
</div>
);
particle: MediaParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
export function MediaParticleView({
particle,
paused,
onEnded,
onProgress,
}: MediaParticleViewProps) {
const cachedUrl = usePlaybackStore(
(s) => s.downloadUrlCache[particle.id],
);
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
const next = usePlaybackStore((s) => s.next);
const paused = usePlaybackStore((s) => s.paused);
const [error, setError] = useState<string | null>(null);
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [currentTimeMs, setCurrentTimeMs] = useState(0);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
const data = particle.data as MediaParticleData;
const isAudio = data.mime_type?.startsWith("audio/");
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
const audioSource = useAudioSource(audioEl);
useEffect(() => {
if (cachedUrl) {
return;
}
let cancelled = false;
apiClient
.getParticleDownloadUrl(particle.id)
.then((downloadUrl) => {
if (cancelled) return;
cacheDownloadUrl(particle.id, downloadUrl);
})
.catch(() => {
if (!cancelled) setError("Failed to load media");
});
return () => {
cancelled = true;
};
}, [particle.id, cacheDownloadUrl]);
// Handle pause/resume
useEffect(() => {
var el: HTMLVideoElement | HTMLAudioElement | null = null;
if (isAudio) {
el = audioRef.current;
} else {
el = videoRef.current;
}
const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
@@ -90,17 +41,17 @@ export function MediaParticleView({
console.warn("Playback failed", { particleId: particle.id });
});
}
}, [paused]);
}, [paused, isAudio, particle.id]);
if (error) {
return (
<div className="text-muted-foreground flex items-center justify-center text-sm">
{error}
Failed to load media
</div>
);
}
if (!cachedUrl) {
if (!url) {
return <Skeleton className="h-full w-full rounded-none" />;
}
@@ -108,20 +59,25 @@ export function MediaParticleView({
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<audio
ref={audioRef}
ref={(el) => {
audioRef.current = el;
setAudioEl(el);
}}
crossOrigin="anonymous"
src={cachedUrl}
src={url}
autoPlay
onEnded={next}
onEnded={onEnded}
onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
/>
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={data.duration_ms}
/>
{audioSource && (
<div className="z-10 absolute bottom-15">
<AudioLevelBars sourceNode={audioSource.sourceNode} />
</div>
)}
</div>
);
}
@@ -130,19 +86,16 @@ export function MediaParticleView({
<div className="relative h-full w-full">
<video
ref={videoRef}
src={cachedUrl}
src={url}
autoPlay
playsInline
onEnded={next}
onEnded={onEnded}
onTimeUpdate={(e) => {
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
const { currentTime, duration } = e.currentTarget;
if (duration > 0) onProgress?.(currentTime / duration);
}}
className="h-full w-full object-cover"
/>
<DurationPill
currentTimeMs={currentTimeMs}
totalDurationMs={data.duration_ms}
/>
</div>
);
}
@@ -1,62 +0,0 @@
import { useEffect, useRef } from "react";
import type { StreamParticle } from "@/api/types";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { MediaParticleView } from "./media-particle-view";
import { TextParticleView } from "./text-particle-view";
import { FallbackParticleView } from "./fallback-particle-view";
import { AckButton } from "./ack-button";
interface ParticleRendererProps {
particle: StreamParticle;
}
export function ParticleRenderer({
particle,
}: ParticleRendererProps) {
const next = usePlaybackStore((s) => s.next);
const prev = usePlaybackStore((s) => s.prev);
const markParticlesSeen = useAppStore((s) => s.markParticlesSeen);
const markedRef = useRef<string | null>(null);
useEffect(() => {
if (!particle.seen && markedRef.current !== particle.id) {
markedRef.current = particle.id;
markParticlesSeen([particle.id]);
apiClient.markSeen(particle.id);
}
}, [particle.id, particle.seen, markParticlesSeen]);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) prev();
else if (x > 0.7) next();
};
return (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handleClick}
>
<ParticleContent particle={particle} />
<div className="absolute right-4 bottom-16">
<AckButton particleId={particle.id} acks={particle.acks} />
</div>
</div>
);
}
function ParticleContent({ particle }: { particle: StreamParticle }) {
switch (particle.type) {
case "media":
{/* NOTE: it's more robust to re-mount the MediaParticleView when the particle changes, to ensure playback state is well-behaved */ }
return <MediaParticleView key={particle.id} particle={particle} />;
case "text":
return <TextParticleView particle={particle} />;
default:
return <FallbackParticleView particle={particle} />;
}
}
@@ -3,12 +3,14 @@ import { cn } from "@/lib/utils";
interface PlaybackPageIndicatorProps {
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
}
export function PlaybackPageIndicator({
total,
current,
progress,
onGoTo,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
}}
className="group relative h-3 flex-1"
>
{/* Track */}
{/* Dim track */}
<div
className={cn(
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
i <= current ? "bg-white/90" : "bg-white/30",
"absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
"group-hover:h-1.5 group-hover:top-0.5",
)}
/>
{/* Fill */}
<div
className={cn(
"absolute left-0 top-1 h-1 rounded-full bg-white/90",
"group-hover:h-1.5 group-hover:top-0.5",
)}
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
))}
</div>
@@ -1,8 +1,25 @@
import type { StreamParticle, TextParticleData } from "@/api/types";
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: StreamParticle;
particle: TextParticle;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const WORDS_PER_MINUTE = 200;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const wordCount = text.trim().split(/\s+/).length;
const seconds = (wordCount / WORDS_PER_MINUTE) * 60;
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
}
function getTextStyle(length: number) {
@@ -12,9 +29,37 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" };
}
export function TextParticleView({ particle }: TextParticleViewProps) {
const data = particle.data as TextParticleData;
const style = getTextStyle(data.content.length);
export function TextParticleView({
particle,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const style = getTextStyle(particle.properties.content.length);
const durationS = computeReadDuration(particle.properties.content);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
useEffect(() => {
elapsedRef.current = 0;
}, [particle.id]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress?.(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
@@ -25,7 +70,7 @@ export function TextParticleView({ particle }: TextParticleViewProps) {
style.weight,
)}
>
{data.content}
{particle.properties.content}
</p>
</div>
);
-53
View File
@@ -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 (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
className={cn(
"rounded p-1 transition-colors hover:bg-white/20",
"text-white/60 hover:text-white/90",
)}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
>
{recordingMode === "video" ? (
<Video className="h-3.5 w-3.5" />
) : (
<Mic className="h-3.5 w-3.5" />
)}
</button>
<div className="text-muted-foreground text-xs">
Hold{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
`
</kbd>{" "}
to reply · Press{" "}
<kbd
className={cn(
"bg-muted rounded px-1.5 py-0.5 font-mono text-xs",
)}
>
T
</kbd>{" "}
to text
</div>
</div>
);
}
-191
View File
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
const AUDIO_FALLBACK_MIME = "audio/webm";
function getMediaMime(mode: "video" | "audio"): string {
if (mode === "audio") {
if (MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME))
return AUDIO_PREFERRED_MIME;
return AUDIO_FALLBACK_MIME;
}
if (MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME))
return VIDEO_PREFERRED_MIME;
return VIDEO_FALLBACK_MIME;
}
export function useRecorder(
streamId: string | null,
networkId: string | null,
) {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const mimeRef = useRef<string>("");
const recordingMode = useRecordingStore((s) => s.recordingMode);
const setStatus = useRecordingStore((s) => s.setStatus);
const setError = useRecordingStore((s) => s.setError);
const setMediaStream = useRecordingStore((s) => s.setMediaStream);
const setReviewBlob = useRecordingStore((s) => s.setReviewBlob);
const resetRecording = useRecordingStore((s) => s.reset);
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setMediaStream(null);
}, [setMediaStream]);
const confirmSend = useCallback(async () => {
if (!streamId || !networkId) return;
const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
if (!reviewBlob) return;
setStatus("uploading");
try {
const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
const fileName = `recording-${Date.now()}.webm`;
const { object_id, upload_url } = await apiClient.prepareUpload({
network_id: networkId,
name: fileName,
content_type: mimeType,
content_length: reviewBlob.size,
});
await fetch(upload_url, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: reviewBlob,
});
await apiClient.confirmUpload(object_id);
const particle = await apiClient.createStreamParticle(streamId, {
type: "media",
data: {
object_id,
duration_ms: reviewDurationMs,
mime_type: mimeType,
},
});
addParticleToStream(streamId, particle);
const playbackState = usePlaybackStore.getState();
if (playbackState.streamId === streamId) {
usePlaybackStore.setState({
particles: [...playbackState.particles, particle],
});
}
resetRecording();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
}, [streamId, networkId, setStatus, setError, resetRecording, addParticleToStream]);
const startRecording = useCallback(async () => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus !== "idle") return;
try {
const constraints =
recordingMode === "video"
? { video: true, audio: true }
: { audio: true };
setStatus("recording");
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setMediaStream(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode);
mimeRef.current = mime;
const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
recorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
const durationMs = Date.now() - startTimeRef.current;
const blob = new Blob(chunksRef.current, { type: mime });
stopTracks();
if (blob.size > 0) {
setReviewBlob(blob, durationMs);
} else {
resetRecording();
}
};
recorder.start();
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [
recordingMode,
setStatus,
setError,
setMediaStream,
setReviewBlob,
stopTracks,
resetRecording,
]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
}, []);
const cancelRecording = useCallback(() => {
const currentStatus = useRecordingStore.getState().status;
if (currentStatus === "reviewing") {
resetRecording();
return;
}
if (recorderRef.current) {
recorderRef.current.ondataavailable = null;
recorderRef.current.onstop = null;
if (recorderRef.current.state === "recording") {
recorderRef.current.stop();
}
}
stopTracks();
resetRecording();
}, [stopTracks, resetRecording]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopTracks();
};
}, [stopTracks]);
return { startRecording, stopRecording, cancelRecording, confirmSend };
}
@@ -59,7 +59,7 @@ function SettingsGroup({
);
}
export function SettingsPage() {
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
@@ -68,7 +68,7 @@ export function SettingsPage() {
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<WindowControls />
<Button
variant="ghost"
@@ -1,112 +0,0 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiClient } from "@/api/client";
import { useAppStore } from "@/stores/app-store";
import type { CreateStreamRequest } from "@/api/types";
interface CreateStreamDialogProps {
networkId: string;
children: React.ReactNode;
}
export function CreateStreamDialog({
networkId,
children,
}: CreateStreamDialogProps) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [visibility, setVisibility] =
useState<CreateStreamRequest["visibility"]>("network_all");
const [isCreating, setIsCreating] = useState(false);
const addStream = useAppStore((s) => s.addStream);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setIsCreating(true);
try {
const stream = await apiClient.createStream(networkId, {
name: name.trim(),
description: description.trim(),
visibility,
});
addStream(networkId, stream);
setOpen(false);
setName("");
setDescription("");
setVisibility("network_all");
} finally {
setIsCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Stream</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="stream-name">Name</Label>
<Input
id="stream-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Stream name"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="stream-description">Description</Label>
<Input
id="stream-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="flex flex-col gap-2">
<Label>Visibility</Label>
<Select
value={visibility}
onValueChange={(v) =>
setVisibility(v as CreateStreamRequest["visibility"])
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="network_all">Everyone in network</SelectItem>
<SelectItem value="custom">Custom members</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={!name.trim() || isCreating}>
{isCreating ? "Creating..." : "Create Stream"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
+37 -8
View File
@@ -1,24 +1,53 @@
import { useMutation } from "@tanstack/react-query";
import { createParticle } from "@/lib/firestore-particles";
import { createParticle, updateStreamLastChildAt } from "@/lib/firestore-particles";
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
interface CreateParticleParams<T extends ParticleType = ParticleType> {
collectionPath: string;
// Path to which the new particle will be added as a child
path: ParticlePath;
type: T;
properties: ParticlePropertiesMap[T];
createdByEmail: string;
visibleTo: string[];
}
export function useCreateParticle() {
return useMutation({
mutationFn: (params: CreateParticleParams) =>
createParticle(
params.collectionPath,
mutationFn: async (params: CreateParticleParams) => {
const collectionPath = toFirestoreChildrenPath(params.path);
const result = await createParticle(
collectionPath,
params.type,
params.properties,
params.createdByEmail,
params.visibleTo,
),
);
const streamDocPath = toFirestoreDocPath(params.path);
await updateStreamLastChildAt(streamDocPath);
return result;
}
});
}
type CreateStreamParticleParams = {
networkId: string;
properties: ParticlePropertiesMap["stream"];
createdByEmail: string;
visibleTo?: string[];
};
export function useCreateStreamParticle() {
return useMutation({
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
return await createParticle(
networkCollectionPath,
"stream",
params.properties,
params.createdByEmail,
params.visibleTo,
);
}
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
export function useDownloadUrl(objectId: string) {
return useQuery({
queryKey: ["download-url", objectId],
queryFn: () => apiClient.getParticleDownloadUrl(objectId),
});
}
+5
View File
@@ -7,3 +7,8 @@ export function useNetworks() {
queryFn: () => apiClient.listNetworks(),
});
}
export function useNetwork(networkId: string) {
const { data: networks } = useNetworks();
return networks?.find((n) => n.id === networkId) || null;
}
-46
View File
@@ -1,46 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticleChildren } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types";
interface UseParticleChildrenResult {
children: Particle[];
isLoading: boolean;
error: Error | null;
}
export function useParticleChildren(
networkId: string,
parentSegments: string[],
): UseParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const collectionPath = useMemo(() => {
if (parentSegments.length === 0) return firestorePath(networkId, []);
return `${firestorePath(networkId, parentSegments)}/children`;
}, [networkId, parentSegments.join("/")]);
useEffect(() => {
setIsLoading(true);
setError(null);
setChildren([]);
const unsubscribe = subscribeToParticleChildren(
collectionPath,
(data) => {
setChildren(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
);
return unsubscribe;
}, [collectionPath]);
return { children, isLoading, error };
}
+106 -13
View File
@@ -1,26 +1,30 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticle } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths";
import {
subscribeToParticle,
subscribeToParticleChildren,
subscribeToLatestChild,
getParticle,
} from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
import {
type ParticlePath,
toFirestoreDocPath,
toFirestoreChildrenPath,
} from "@/lib/particle-path";
import { useQuery } from "@tanstack/react-query";
interface UseParticleResult {
interface UseLiveParticleResult {
particle: Particle | null;
isLoading: boolean;
error: Error | null;
}
export function useParticle(
networkId: string,
segments: string[],
): UseParticleResult {
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [particle, setParticle] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const path = useMemo(
() => firestorePath(networkId, segments),
[networkId, segments.join("/")],
);
const docPath = useMemo(() => toFirestoreDocPath(path), [path]);
useEffect(() => {
setIsLoading(true);
@@ -28,7 +32,7 @@ export function useParticle(
setParticle(null);
const unsubscribe = subscribeToParticle(
path,
docPath,
(data) => {
setParticle(data);
setIsLoading(false);
@@ -40,7 +44,96 @@ export function useParticle(
);
return unsubscribe;
}, [path]);
}, [docPath]);
return { particle, isLoading, error };
}
interface UseLiveParticleChildrenResult {
children: Particle[];
isLoading: boolean;
error: Error | null;
}
export function useLiveParticleChildren(
path: ParticlePath,
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
visibilityScopes?: string[],
): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
useEffect(() => {
setIsLoading(true);
setError(null);
setChildren([]);
const unsubscribe = subscribeToParticleChildren(
collectionPath,
(data) => {
setChildren(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
);
return unsubscribe;
}, [collectionPath]);
return { children, isLoading, error };
}
interface UseLiveLatestChildResult {
latestChild: Particle | null;
isLoading: boolean;
}
export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult {
const [latestChild, setLatestChild] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
collectionPath,
(data) => {
setLatestChild(data);
setIsLoading(false);
},
() => {
setIsLoading(false);
},
);
return unsubscribe;
}, [collectionPath]);
return { latestChild, isLoading };
}
export function useParticle(path?: ParticlePath) {
return useQuery({
queryKey: ["particle", path],
queryFn: async () => {
if (!path) return null;
const docPath = toFirestoreDocPath(path);
const particle = await getParticle(docPath);
return particle;
},
enabled: !!path,
});
}
+19
View File
@@ -0,0 +1,19 @@
import { useState, useCallback } from "react";
export type RecordingMode = "video" | "audio";
const KEY = "llink:recording-mode";
export function useRecordingMode(): [RecordingMode, (mode: RecordingMode) => void] {
const [mode, setModeState] = useState<RecordingMode>(() => {
const stored = localStorage.getItem(KEY);
return stored === "audio" ? "audio" : "video";
});
const setMode = useCallback((m: RecordingMode) => {
localStorage.setItem(KEY, m);
setModeState(m);
}, []);
return [mode, setMode];
}
+161 -17
View File
@@ -3,10 +3,13 @@ import {
doc,
onSnapshot,
addDoc,
getDoc,
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
where,
Timestamp,
type DocumentData,
type FirestoreDataConverter,
@@ -15,7 +18,7 @@ import {
type Unsubscribe,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { ParticleSchema } from "@/api/types";
import { isContainerType, ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
// --- Converter ---
@@ -34,15 +37,56 @@ const particleConverter: FirestoreDataConverter<Particle> = {
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
if (typeof raw.type !== "string") {
throw new Error(`Invalid particle type: ${raw.type}`);
}
const type = raw.type as ParticleType;
switch (type) {
case "stream":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
playback_markers: raw.playback_markers
? Object.fromEntries(
Object.entries(raw.playback_markers).map(([key, value]) => [
key,
(value as Timestamp).toDate(),
]),
)
: undefined,
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
});
case "folder":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
case "media":
case "file":
case "text":
case "quest":
case "paper":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
});
default:
throw new Error(`Unknown particle type: ${type}`);
}
},
};
@@ -72,12 +116,30 @@ export function subscribeToParticle(
);
}
export async function getParticle(docPath: string): Promise<Particle | null> {
const doc = await getDoc(typedDoc(docPath));
if (!doc.exists()) {
return null;
}
return doc.data();
}
export function subscribeToParticleChildren(
collectionPath: string,
onData: (children: Particle[]) => void,
onError: (error: Error) => void,
visibilityScopes: string[] = [],
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
): Unsubscribe {
const q = query(typedCollection(collectionPath), orderBy("created_at"));
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
if (visibilityScopes.length > 0) {
q = query(
q,
where("visible_to", "array-contains-any", visibilityScopes),
);
}
return onSnapshot(
q,
(snap) => {
@@ -87,31 +149,56 @@ export function subscribeToParticleChildren(
);
}
export function subscribeToLatestChild(
collectionPath: string,
onData: (child: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy("created_at", "desc"),
limit(1),
);
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
},
onError,
);
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: string,
visibleTo: string[],
// Must be passed for container types
visibleTo?: string[],
): Promise<string> {
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
throw new Error(
`visibleTo is required for container type ${type} and cannot be empty`,
);
}
const particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_by_email: createdByEmail,
updated_at: null,
visible_to: visibleTo,
...(visibleTo ? { visible_to: visibleTo } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticle<T extends ParticleType>(
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
visibleTo?: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
@@ -123,6 +210,63 @@ export async function updateParticle<T extends ParticleType>(
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
...(visibleTo ? { visible_to: visibleTo } : {}),
});
}
export async function updateParticleVisibleTo(
docPath: string,
visibleTo: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
const particle = await getParticle(docPath);
if (!particle) {
throw new Error(`Particle not found at path: ${docPath}`);
}
if (!isContainerType(particle.type)) {
throw new Error(
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
);
}
await updateDoc(particleRef, {
visible_to: visibleTo,
updated_at: serverTimestamp(),
});
}
// CAUTION: use the other type safe update functions in most cases
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
export async function updateParticle(
docPath: string,
fieldName: string,
value: any,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
updated_at: serverTimestamp(),
});
}
export async function updateStreamLastChildAt(
docPath: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
last_child_created_at: serverTimestamp(),
updated_at: serverTimestamp(),
});
}
export async function updateStreamPlaybackMarker(
docPath: string,
humanId: string,
playbackPositionAt: Date,
): Promise<void> {
const particleRef = typedDoc(docPath);
const markerField = `playback_markers.${humanId}`;
await updateDoc(particleRef, {
[markerField]: Timestamp.fromDate(playbackPositionAt),
updated_at: serverTimestamp(),
});
}
-23
View File
@@ -1,23 +0,0 @@
/**
* Map URL segments to Firestore paths.
*
* Firestore structure:
* networks/{networkId}/particles/{particleId}
* networks/{networkId}/particles/{particleId}/children/{childId}
* ...and so on for arbitrary depth.
*
* Examples:
* segments = [] → "networks/{nid}/particles"
* segments = ["p1"] → "networks/{nid}/particles/p1"
* segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
*/
export function firestorePath(networkId: string, segments: string[]): string {
const base = `networks/${networkId}/particles`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
+67
View File
@@ -0,0 +1,67 @@
/**
* ParticlePath is a branded string type representing a URL-style path
* to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
*
* Using a branded type prevents accidentally passing raw strings where
* a validated particle path is expected.
*/
declare const __brand: unique symbol;
export type ParticlePath = string & { readonly [__brand]: true };
/**
* Construct a ParticlePath from a network ID and optional particle segments.
*
* @example
* particlePath("net1", []) // => "/net1"
* particlePath("net1", ["p1"]) // => "/net1/p1"
* particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
*/
export function particlePath(networkId: string, segments: string[] = []): ParticlePath {
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
}
/**
* Parse a ParticlePath back into its network ID and particle segments.
*/
export function parseParticlePath(path: ParticlePath): {
networkId: string;
segments: string[];
} {
const parts = path.split("/").filter(Boolean);
return { networkId: parts[0], segments: parts.slice(1) };
}
/**
* Convert a ParticlePath to the Firestore document path for that particle.
*
* Firestore structure:
* /net1 → networks/net1/particles (collection)
* /net1/p1 → networks/net1/particles/p1 (document)
* /net1/p1/p2 → networks/net1/particles/p1/children/p2 (document)
*/
export function toFirestoreDocPath(path: ParticlePath): string {
const { networkId, segments } = parseParticlePath(path);
const base = `networks/${networkId}/particles`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
/**
* Convert a ParticlePath to the Firestore collection path for its children.
*
* /net1 → networks/net1/particles (root particles)
* /net1/p1 → networks/net1/particles/p1/children
* /net1/p1/p2 → networks/net1/particles/p1/children/p2/children
*/
export function toFirestoreChildrenPath(path: ParticlePath): string {
const { segments } = parseParticlePath(path);
if (segments.length === 0) {
return toFirestoreDocPath(path);
}
return `${toFirestoreDocPath(path)}/children`;
}
+19
View File
@@ -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}`;
}
+5
View File
@@ -7,6 +7,11 @@ if (started) {
app.quit();
}
// Set the dock icon for development mode on macOS.
if (process.platform === 'darwin' && !app.isPackaged) {
app.dock.setIcon(path.join(__dirname, '../../assets/icon.png'));
}
const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
-285
View File
@@ -1,285 +0,0 @@
import { useEffect, useCallback, useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useAppStore } from "@/stores/app-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRecordingStore } from "@/stores/recording-store";
import { ParticleRenderer } from "@/features/playback/particle-renderer";
import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
import { ReplyIndicator } from "@/features/send/reply-indicator";
import { TextComposeOverlay } from "@/features/send/text-compose-overlay";
import { RecordingOverlay } from "@/features/send/recording-overlay";
import { useRecorder } from "@/features/send/use-recorder";
export function StreamPlayerPage() {
const { streamId } = useParams<{ streamId: string }>();
const navigate = useNavigate();
const networks = useAppStore((s) => s.networks);
const [composingText, setComposingText] = useState(false);
const [showRecordingOverlay, setShowRecordingOverlay] = useState(false);
const particles = usePlaybackStore((s) => s.particles);
const currentIndex = usePlaybackStore((s) => s.currentIndex);
const initStream = usePlaybackStore((s) => s.initStream);
const next = usePlaybackStore((s) => s.next);
const prev = usePlaybackStore((s) => s.prev);
const goTo = usePlaybackStore((s) => s.goTo);
const reset = usePlaybackStore((s) => s.reset);
const pause = usePlaybackStore((s) => s.pause);
const resume = usePlaybackStore((s) => s.resume);
const networkForStream = useMemo(() => {
return networks.find((n) =>
n.streams.some((s) => s.id === streamId),
);
}, [networks, streamId]);
const stream = useMemo(() => {
if (!networkForStream) return null;
return networkForStream.streams.find((s) => s.id === streamId) || null;
}, [networkForStream, streamId]);
const { startRecording, stopRecording, cancelRecording, confirmSend } =
useRecorder(streamId ?? null, networkForStream?.id ?? null);
useEffect(() => {
if (!stream) return;
const firstUnseenIndex = stream.particles.findIndex((p) => !p.seen);
const startIndex =
firstUnseenIndex >= 0
? firstUnseenIndex
: Math.max(0, stream.particles.length - 1);
initStream(stream.id, stream.particles, startIndex);
return () => {
reset();
};
}, [stream?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRecordingOverlayClose = useCallback(() => {
setShowRecordingOverlay(false);
resume();
}, [resume]);
// Unified keyboard handling
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (composingText) return;
const recStatus = useRecordingStore.getState().status;
// Q/Esc during recording or reviewing: cancel immediately
if (
(e.key === "q" || e.key === "Q" || e.key === "Escape") &&
(recStatus === "recording" || recStatus === "reviewing")
) {
e.preventDefault();
cancelRecording();
setShowRecordingOverlay(false);
resume();
return;
}
// Enter or backtick during reviewing: send
if (
(e.key === "Enter" || e.key === "`") &&
recStatus === "reviewing"
) {
e.preventDefault();
confirmSend();
return;
}
// Block navigation while recording/uploading
if (recStatus === "recording" || recStatus === "uploading") {
return;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
pause();
startRecording();
setShowRecordingOverlay(true);
} else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
prev();
} else if (e.key === "Escape") {
navigate("/");
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setComposingText(true);
}
},
[
composingText,
next,
prev,
navigate,
pause,
resume,
startRecording,
cancelRecording,
confirmSend,
],
);
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
if (composingText) return;
const recStatus = useRecordingStore.getState().status;
if (e.key === "`" && recStatus === "recording") {
e.preventDefault();
stopRecording();
// Transitions to reviewing — overlay stays open
}
},
[composingText, stopRecording],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
if (!stream) {
return (
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Stream not found</p>
</div>
);
}
if (particles.length === 0) {
return (
<div className="flex h-screen flex-col bg-black text-white">
{/* Top overlay */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10 px-2 pt-2">
<div className="pointer-events-auto inline-flex">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">
No particles yet. Hold ` to record the first one.
</p>
</div>
<div className="absolute right-0 bottom-0 left-0 z-10 flex items-center justify-between px-4 py-3">
<span className="text-xs font-medium text-white/70">
{stream.name}
</span>
<ReplyIndicator />
</div>
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
const currentParticle = particles[currentIndex];
return (
<div className="relative h-screen bg-black text-white">
{/* Particle content — fills entire viewport */}
<div className="absolute inset-0">
{currentParticle && (
<ParticleRenderer particle={currentParticle} />
)}
</div>
{/* Top overlay: progress bars + back button */}
<div className="pointer-events-none absolute top-0 right-0 left-0 z-10">
<div className="pointer-events-auto">
<PlaybackPageIndicator
total={particles.length}
current={currentIndex}
onGoTo={goTo}
/>
</div>
<div className="pointer-events-auto mt-1 inline-flex px-2">
<Button
variant="ghost"
size="icon"
className="rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-black/50"
onClick={() => navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
</div>
</div>
{/* Top center overlay for particle author avatar and name */}
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 z-10 mt-3 rounded-full bg-white/10 px-1 py-1 backdrop-blur-sm">
<div className="flex items-center gap-1">
{currentParticle && (
<Avatar size="sm">
<AvatarFallback className="bg-white/20 text-[10px] text-white">
{currentParticle.created_by_email
.split("@")[0]
.slice(0, 2)
.toUpperCase()}
</AvatarFallback>
</Avatar>
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium text-white/70 pr-1">
{currentParticle?.created_by_email.split("@")[0]}
<span className="text-white/40"> &middot; </span>
{stream.name}
</span>
</div>
</div>
{/* Bottom overlay: stream info + reply */}
<div className="absolute right-0 bottom-0 left-0 z-10 flex justify-center px-3 pb-3">
<div className="flex w-full items-center gap-2.5 rounded-full bg-black/30 px-3 py-2 backdrop-blur-sm">
<div className="flex-1 text-left text-sm font-medium text-white/70">
_this is a placeholder for captions_
</div>
<ReplyIndicator />
</div>
</div>
{/* Text compose overlay */}
{composingText && streamId && (
<TextComposeOverlay
streamId={streamId}
onClose={() => setComposingText(false)}
/>
)}
{/* Recording overlay */}
{showRecordingOverlay && (
<RecordingOverlay onClose={handleRecordingOverlayClose} />
)}
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
import { create } from "zustand";
/**
* Minimal app-level store. Navigation state is now URL-driven via PathResolver.
* Stream/particle state will move to Firestore hooks.
*/
interface AppState {
selectedNetworkId: string | null;
setSelectedNetwork: (id: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
selectedNetworkId: null,
setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
}));
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
export type RecordingMode = "video" | "audio";
const KEY = "llink:recording-mode";
interface MediaSettingsState {
recordingMode: RecordingMode;
setRecordingMode: (mode: RecordingMode) => void;
}
export const useMediaSettingsStore = create<MediaSettingsState>((set) => ({
recordingMode: (() => {
const stored = localStorage.getItem(KEY);
return stored === "audio" ? "audio" : "video";
})(),
setRecordingMode: (mode) => {
localStorage.setItem(KEY, mode);
set({ recordingMode: mode });
},
}));
-88
View File
@@ -1,88 +0,0 @@
import { create } from "zustand";
import type { StreamParticle } from "@/api/types";
type PlaybackStatus = "idle" | "playing" | "ended";
interface PlaybackState {
streamId: string | null;
particles: StreamParticle[];
currentIndex: number;
status: PlaybackStatus;
paused: boolean;
downloadUrlCache: Record<string, string>;
initStream: (
streamId: string,
particles: StreamParticle[],
startIndex: number,
) => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
pause: () => void;
resume: () => void;
cacheDownloadUrl: (particleId: string, url: string) => void;
reset: () => void;
}
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
initStream: (streamId, particles, startIndex) => {
set({
streamId,
particles,
currentIndex: startIndex,
status: particles.length > 0 ? "playing" : "ended",
downloadUrlCache: {},
});
},
next: () => {
const { currentIndex, particles } = get();
if (currentIndex < particles.length - 1) {
set({ currentIndex: currentIndex + 1, paused: false });
} else {
set({ status: "ended", paused: false });
}
},
prev: () => {
const { currentIndex } = get();
if (currentIndex > 0) {
set({ currentIndex: currentIndex - 1, status: "playing", paused: false });
}
},
goTo: (index) => {
const { particles } = get();
if (index >= 0 && index < particles.length) {
set({ currentIndex: index, status: "playing", paused: false });
}
},
pause: () => set({ paused: true }),
resume: () => set({ paused: false }),
cacheDownloadUrl: (particleId, url) => {
set({
downloadUrlCache: { ...get().downloadUrlCache, [particleId]: url },
});
},
reset: () => {
set({
streamId: null,
particles: [],
currentIndex: 0,
status: "idle",
paused: false,
downloadUrlCache: {},
});
},
}));
-54
View File
@@ -1,54 +0,0 @@
import { create } from "zustand";
type RecordingStatus = "idle" | "recording" | "reviewing" | "uploading" | "error";
type RecordingMode = "video" | "audio";
const RECORDING_MODE_KEY = "llink:recording-mode";
function loadRecordingMode(): RecordingMode {
const stored = localStorage.getItem(RECORDING_MODE_KEY);
return stored === "audio" ? "audio" : "video";
}
interface RecordingState {
status: RecordingStatus;
error: string | null;
mediaStream: MediaStream | null;
recordingMode: RecordingMode;
reviewBlob: Blob | null;
reviewDurationMs: number;
setStatus: (status: RecordingStatus) => void;
setError: (error: string) => void;
setMediaStream: (stream: MediaStream | null) => void;
setRecordingMode: (mode: RecordingMode) => void;
setReviewBlob: (blob: Blob, durationMs: number) => void;
reset: () => void;
}
export const useRecordingStore = create<RecordingState>((set) => ({
status: "idle",
error: null,
mediaStream: null,
recordingMode: loadRecordingMode(),
reviewBlob: null,
reviewDurationMs: 0,
setStatus: (status) => set({ status, error: null }),
setError: (error) => set({ status: "error", error }),
setMediaStream: (mediaStream) => set({ mediaStream }),
setRecordingMode: (recordingMode) => {
localStorage.setItem(RECORDING_MODE_KEY, recordingMode);
set({ recordingMode });
},
setReviewBlob: (reviewBlob, reviewDurationMs) =>
set({ status: "reviewing", reviewBlob, reviewDurationMs }),
reset: () =>
set({
status: "idle",
error: null,
mediaStream: null,
reviewBlob: null,
reviewDurationMs: 0,
}),
}));