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:
@@ -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 |
@@ -10,6 +10,7 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
icon: './assets/flowy',
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
|
||||
+3
-3
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
-46
@@ -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>
|
||||
+20
-40
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,7 @@ function NetworkRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkSelector() {
|
||||
export default function NetworkSelector() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isPending, error } = useNetworks();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-43
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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"> · </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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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: {},
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user