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)
|
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) {
|
func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
_, ok := middleware.EmailFromContext(r.Context())
|
_, ok := middleware.EmailFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -459,33 +459,6 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
objectID := r.PathValue("id")
|
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)
|
downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get download URL", "error", err, "object_id", objectID)
|
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
|
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 = {
|
const config: ForgeConfig = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: true,
|
||||||
|
icon: './assets/flowy',
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
makers: [
|
makers: [
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "js",
|
"name": "llink",
|
||||||
"productName": "js",
|
"productName": "llink",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "My Electron application description",
|
"description": "Flowy.llink is a team communication app for teams",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+14
-5
@@ -8,8 +8,11 @@ import {
|
|||||||
QueryClient,
|
QueryClient,
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import PathResolver from "./pages/path-resolver";
|
import SettingsPage from "@/features/settings-page";
|
||||||
import { SettingsPage } from "./pages/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();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -37,12 +40,18 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AuthenticatedApp() {
|
function AuthenticatedApp() {
|
||||||
// NOTE: Hash router provides history, despite using catch-all
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="/*" element={<PathResolver />} />
|
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<NetworkSelector />} />
|
||||||
|
<Route path=":networkId">
|
||||||
|
<Route index element={<NetworkRoot />} />
|
||||||
|
<Route path="*" element={<ParticleViewResolver />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class ApiClient {
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {};
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
if (body) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
const token = this.config.getToken();
|
const token = this.config.getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -115,7 +117,8 @@ class ApiClient {
|
|||||||
"GET",
|
"GET",
|
||||||
`/particles/${objectId}/download`,
|
`/particles/${objectId}/download`,
|
||||||
);
|
);
|
||||||
return response.url;
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Depot ---
|
// --- Depot ---
|
||||||
|
|||||||
+17
-5
@@ -128,14 +128,26 @@ const ParticleBaseSchema = z.object({
|
|||||||
created_at: z.coerce.date(),
|
created_at: z.coerce.date(),
|
||||||
created_by_email: z.string().email(),
|
created_by_email: z.string().email(),
|
||||||
updated_at: z.coerce.date().optional(),
|
updated_at: z.coerce.date().optional(),
|
||||||
// e.g. ["human:[email protected]", "human:[email protected]"] - 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", [
|
export const ParticleSchema = z.discriminatedUnion("type", [
|
||||||
ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
|
ParticleBaseSchema.extend({
|
||||||
ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
|
type: z.literal("stream"), properties: StreamPropertiesSchema,
|
||||||
|
// e.g. ["human:[email protected]", "human:[email protected]"] - 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:[email protected]", "human:[email protected]"] - 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("media"), properties: MediaPropertiesSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
|
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
|
||||||
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
|
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 { 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 { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
|
||||||
interface RecordingOverlayProps {
|
interface RecordingOverlayProps {
|
||||||
|
step: "recording" | "reviewing";
|
||||||
|
mediaStream: MediaStream | null;
|
||||||
|
recordingMode: RecordingMode;
|
||||||
|
reviewBlob: Blob | null;
|
||||||
|
error: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,24 +92,17 @@ function ReviewPlayback({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
export function RecordingOverlay({
|
||||||
const status = useRecordingStore((s) => s.status);
|
step,
|
||||||
const mediaStream = useRecordingStore((s) => s.mediaStream);
|
mediaStream,
|
||||||
const recordingMode = useRecordingStore((s) => s.recordingMode);
|
recordingMode,
|
||||||
const reviewBlob = useRecordingStore((s) => s.reviewBlob);
|
reviewBlob,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
}: RecordingOverlayProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hasBeenActiveRef = useRef(false);
|
|
||||||
const recordingAudioSource = useAudioSource(mediaStream ?? null);
|
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
|
// Set video srcObject for live preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (videoRef.current && mediaStream && recordingMode === "video") {
|
if (videoRef.current && mediaStream && recordingMode === "video") {
|
||||||
@@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
}
|
}
|
||||||
}, [mediaStream, recordingMode]);
|
}, [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
|
// Auto-close after error with a brief delay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "error") return;
|
if (!error) return;
|
||||||
const timeout = setTimeout(onClose, 1500);
|
const timeout = setTimeout(onClose, 1500);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [status, onClose]);
|
}, [error, onClose]);
|
||||||
|
|
||||||
const isUploading = status === "uploading";
|
const isReviewing = step === "reviewing";
|
||||||
const isReviewing = status === "reviewing";
|
const isRecording = step === "recording";
|
||||||
const isRecording = status === "recording";
|
|
||||||
|
|
||||||
// Loading: status is recording but media stream hasn't arrived yet
|
|
||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dimmed overlay when uploading */}
|
{/* Top center: recording indicator */}
|
||||||
{isUploading && <div className="absolute inset-0 bg-black/60" />}
|
|
||||||
|
|
||||||
{/* Top center: recording indicator / uploading */}
|
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isUploading ? (
|
{isRecording && !isLoading ? (
|
||||||
<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 ? (
|
|
||||||
<RecordingTimer />
|
<RecordingTimer />
|
||||||
) : isReviewing ? (
|
) : isReviewing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center: audio level bars (recording with active stream) */}
|
{/* Bottom center: audio level bars (recording with active stream) */}
|
||||||
{isRecording && recordingAudioSource && (
|
{isRecording && recordingAudioSource && (
|
||||||
<div className="z-10">
|
<div className="z-10 absolute bottom-15">
|
||||||
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
<AudioLevelBars sourceNode={recordingAudioSource.sourceNode} />
|
||||||
</div>
|
</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">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Enter
|
Enter
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
to send
|
next
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<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 */}
|
{/* Error state */}
|
||||||
{status === "error" && (
|
{error && (
|
||||||
<div className="z-10 text-sm text-red-400">
|
<div className="z-10 text-sm text-red-400">
|
||||||
{useRecordingStore.getState().error ?? "Recording failed"}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/api/client";
|
|
||||||
import { useAppStore } from "@/stores/app-store";
|
|
||||||
|
|
||||||
interface TextComposeOverlayProps {
|
interface TextComposeStepProps {
|
||||||
streamId: string;
|
textContent: string;
|
||||||
onClose: () => void;
|
onTextChange: (text: string) => void;
|
||||||
|
onAdvance: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextStyle(length: number) {
|
function getTextStyle(length: number) {
|
||||||
@@ -15,61 +15,41 @@ function getTextStyle(length: number) {
|
|||||||
return { size: "text-lg", weight: "font-normal" };
|
return { size: "text-lg", weight: "font-normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextComposeOverlay({
|
export function TextComposeStep({
|
||||||
streamId,
|
textContent,
|
||||||
onClose,
|
onTextChange,
|
||||||
}: TextComposeOverlayProps) {
|
onAdvance,
|
||||||
const [content, setContent] = useState("");
|
onCancel,
|
||||||
const [sending, setSending] = useState(false);
|
}: TextComposeStepProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const addParticleToStream = useAppStore((s) => s.addParticleToStream);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus();
|
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(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
onCancel();
|
||||||
onClose();
|
|
||||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
if (textContent.trim()) onAdvance();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose, handleSend],
|
[onCancel, onAdvance, textContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const style = getTextStyle(content.length);
|
const style = getTextStyle(textContent.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={content}
|
value={textContent}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
disabled={sending}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
"w-full max-w-2xl resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||||
style.size,
|
style.size,
|
||||||
@@ -86,9 +66,9 @@ export function TextComposeOverlay({
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Cmd+Enter
|
⌘+Enter
|
||||||
</kbd>{" "}
|
</kbd>{" "}
|
||||||
send
|
next
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 { WindowControls } from "@/components/window-controls";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -15,11 +11,27 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
|
||||||
import { useNetworks } from "@/hooks/use-networks";
|
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[] {
|
function getParticleDisplayName(particle: Particle): string {
|
||||||
return path.split("/").filter(Boolean);
|
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 }) {
|
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 navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const { networkId, "*": rest } = useParams();
|
||||||
const networkId = segments[0] ?? null;
|
const segments = [networkId, ...rest?.split("/") ?? []].filter(Boolean);
|
||||||
|
|
||||||
|
const path = rest ? particlePath(networkId!, rest.split("/").filter(Boolean)) : undefined;
|
||||||
|
|
||||||
|
const { data: particle } = useParticle(path);
|
||||||
|
|
||||||
return (
|
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 />
|
<WindowControls />
|
||||||
|
|
||||||
<Breadcrumb className="no-drag">
|
<Breadcrumb className="no-drag">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem className="text-xs">
|
||||||
{segments.length === 0 ? (
|
{segments.length === 0 ? (
|
||||||
<BreadcrumbPage className="flex items-center gap-1">
|
<BreadcrumbPage className="flex items-center gap-1">
|
||||||
<Home className="size-3.5" />
|
<Home className="size-3.5" />
|
||||||
Networks
|
|
||||||
</BreadcrumbPage>
|
</BreadcrumbPage>
|
||||||
) : (
|
) : (
|
||||||
<BreadcrumbLink
|
<BreadcrumbLink
|
||||||
@@ -63,7 +78,6 @@ function TopBar({ segments }: { segments: string[] }) {
|
|||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
<Home className="size-3.5" />
|
<Home className="size-3.5" />
|
||||||
Networks
|
|
||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
@@ -71,7 +85,7 @@ function TopBar({ segments }: { segments: string[] }) {
|
|||||||
{networkId && (
|
{networkId && (
|
||||||
<span className="contents">
|
<span className="contents">
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem className="text-xs">
|
||||||
{segments.length === 1 ? (
|
{segments.length === 1 ? (
|
||||||
<BreadcrumbPage>
|
<BreadcrumbPage>
|
||||||
<NetworkBreadcrumbContent networkId={networkId} />
|
<NetworkBreadcrumbContent networkId={networkId} />
|
||||||
@@ -88,33 +102,19 @@ function TopBar({ segments }: { segments: string[] }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{segments.slice(1).map((segment, index) => {
|
{particle && (
|
||||||
const isLast = index === segments.length - 2;
|
<span key={path} className="contents">
|
||||||
const path = `/${segments.slice(0, index + 2).join("/")}`;
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem className="text-xs">
|
||||||
return (
|
<BreadcrumbPage>{getParticleDisplayName(particle)}</BreadcrumbPage>
|
||||||
<span key={path} className="contents">
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator />
|
</span>
|
||||||
<BreadcrumbItem>
|
)}
|
||||||
{isLast ? (
|
|
||||||
<BreadcrumbPage>{segment}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => navigate(path)}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -127,41 +127,11 @@ function TopBar({ segments }: { segments: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function Layout() {
|
||||||
* 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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<TopBar segments={segments} />
|
<TopBar />
|
||||||
<div className="flex-1 overflow-hidden">{content}</div>
|
<Outlet />
|
||||||
</div>
|
</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 navigate = useNavigate();
|
||||||
|
|
||||||
const { data, isPending, error } = useNetworks();
|
const { data, isPending, error } = useNetworks();
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { Particle } from "@/api/types";
|
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 {
|
interface FolderViewProps {
|
||||||
folderParticle: Particle;
|
folderParticle: Particle;
|
||||||
networkId: string;
|
path: ParticlePath;
|
||||||
particleSegments: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
|
export function FolderView({ path, folderParticle }: FolderViewProps) {
|
||||||
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
|
const { children, error, isLoading } = useLiveParticleChildren(path);
|
||||||
|
const { networkId } = parseParticlePath(path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Folder view — {networkId}/{particleSegments.join("/")}
|
Folder view — {folderParticle.id}
|
||||||
</p>
|
</p>
|
||||||
|
<ComposeOverlay networkId={networkId} />
|
||||||
</div>
|
</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 {
|
interface ParticleListViewProps {
|
||||||
networkId: string;
|
path: ParticlePath;
|
||||||
particleSegments: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
||||||
const { children, isLoading } = useParticleChildren(networkId, particleSegments);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return <Progress />;
|
||||||
<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 (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 p-4">
|
<ScrollArea className="h-full">
|
||||||
{children.map((child) => (
|
<div className="py-1">
|
||||||
<div
|
{streams.map((stream, index) => (
|
||||||
key={child.id}
|
<div key={stream.id}>
|
||||||
className="rounded-lg border p-3 text-sm"
|
<StreamRow
|
||||||
>
|
particle={stream}
|
||||||
<p className="font-medium">{child.id}</p>
|
networkId={networkId}
|
||||||
<p className="text-muted-foreground text-xs">{child.type}</p>
|
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||||
</div>
|
/>
|
||||||
))}
|
{index < streams.length - 1 && <Separator className="mx-4" />}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-43
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { StreamParticle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { getParticleData } from "@/api/types";
|
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -11,21 +10,8 @@ import {
|
|||||||
FileIcon,
|
FileIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ParticlePreviewProps {
|
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||||
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) {
|
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "text":
|
case "text":
|
||||||
return <TextPreview particle={particle} />;
|
return <TextPreview particle={particle} />;
|
||||||
@@ -44,27 +30,24 @@ export function ParticlePreview({ particle }: ParticlePreviewProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextPreview({ particle }: { particle: StreamParticle }) {
|
function TextPreview({ particle }: { particle: Extract<Particle, { type: "text" }> }) {
|
||||||
const data = getParticleData(particle, "text");
|
const truncated =
|
||||||
// only show x first chars for preview, to avoid overflow and also to determine text size
|
particle.properties.content.length > 30
|
||||||
const truncated = data.content.length > 30 ? data.content.slice(0, 30) + "..." : data.content;
|
? particle.properties.content.slice(0, 30) + "..."
|
||||||
|
: particle.properties.content;
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center p-4">
|
<div className="flex h-full w-full items-center justify-center p-4">
|
||||||
<p
|
<p className="line-clamp-4 text-center text-4xl leading-relaxed">
|
||||||
className={cn(
|
|
||||||
"line-clamp-4 text-center leading-relaxed text-4xl",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{truncated}
|
{truncated}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaPreview({ particle }: { particle: StreamParticle }) {
|
function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media" }> }) {
|
||||||
const data = getParticleData(particle, "media");
|
const { mime_type, duration_ms } = particle.properties;
|
||||||
const isVideo = data.mime_type.startsWith("video");
|
const isVideo = mime_type.startsWith("video");
|
||||||
const durationSec = Math.round(data.duration_ms / 1000);
|
const durationSec = Math.round(duration_ms / 1000);
|
||||||
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
@@ -132,54 +115,51 @@ function VideoThumbnail({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestPreview({ particle }: { particle: StreamParticle }) {
|
function QuestPreview({ particle }: { particle: Extract<Particle, { type: "quest" }> }) {
|
||||||
const data = getParticleData(particle, "quest");
|
const { title, status } = particle.properties;
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-amber-500/10 p-4">
|
<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" />
|
<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">
|
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||||
{data.title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{data.status && (
|
{status && (
|
||||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
|
||||||
{data.status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaperPreview({ particle }: { particle: StreamParticle }) {
|
function PaperPreview({ particle }: { particle: Extract<Particle, { type: "paper" }> }) {
|
||||||
const data = getParticleData(particle, "paper");
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-blue-500/10 p-4">
|
<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" />
|
<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">
|
<p className="line-clamp-2 text-center text-sm font-medium">
|
||||||
{data.title}
|
{particle.properties.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilePreview({ particle }: { particle: StreamParticle }) {
|
function FilePreview({ particle }: { particle: Extract<Particle, { type: "file" }> }) {
|
||||||
const data = getParticleData(particle, "file");
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-emerald-500/10 p-4">
|
<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" />
|
<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">
|
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||||
{data.filename}
|
{particle.properties.filename}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderPreview({ particle }: { particle: StreamParticle }) {
|
function FolderPreview({ particle }: { particle: Extract<Particle, { type: "folder" }> }) {
|
||||||
const data = getParticleData(particle, "folder");
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-orange-500/10 p-4">
|
<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" />
|
<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">
|
<p className="text-muted-foreground line-clamp-1 text-center text-xs">
|
||||||
{data.name}
|
{particle.properties.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useParticle } from "@/hooks/use-particle";
|
import { useParams } from "react-router-dom";
|
||||||
import { StreamView } from "./stream-view";
|
import { useLiveParticle } from "@/hooks/use-particle";
|
||||||
import { FolderView } from "./folder-view";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import { ParticleListView } from "./particle-list-view";
|
|
||||||
import { isContainerType } from "@/api/types";
|
import { isContainerType } from "@/api/types";
|
||||||
|
import { StreamView } from "@/features/particles/stream-view";
|
||||||
interface ParticleViewResolverProps {
|
import { FolderView } from "@/features/particles/folder-view";
|
||||||
networkId: string;
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
particleSegments: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a particle by its path segments and renders the appropriate view
|
* Route-level component for /:networkId/*.
|
||||||
* based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
|
* Reads params from the router, resolves the particle, and renders
|
||||||
|
* the appropriate view based on particle type.
|
||||||
*/
|
*/
|
||||||
export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
|
export default function ParticleViewResolver() {
|
||||||
const { particle, isLoading, error } = useParticle(networkId, particleSegments);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
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) {
|
if (!particle) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Particle: {particleSegments.join(" / ")}
|
Particle: {segments.join(" / ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -45,15 +46,13 @@ export function ParticleViewResolver({ networkId, particleSegments }: ParticleVi
|
|||||||
|
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "stream":
|
case "stream":
|
||||||
return <StreamView streamParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
|
return <StreamView streamParticle={particle} path={path} />;
|
||||||
case "folder":
|
case "folder":
|
||||||
return <FolderView folderParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
|
return <FolderView folderParticle={particle} path={path} />;
|
||||||
default:
|
default:
|
||||||
// For container types we haven't built a view for, fall back to list
|
|
||||||
if (isContainerType(particle.type)) {
|
if (isContainerType(particle.type)) {
|
||||||
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
|
return <ParticleListView path={path} />;
|
||||||
}
|
}
|
||||||
// Leaf particle — placeholder
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -1,36 +1,432 @@
|
|||||||
import { Particle } from "@/api/types";
|
import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react";
|
||||||
import { useParticleChildren } from "@/hooks/use-particle-children";
|
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 {
|
// --- Playback reducer ---
|
||||||
streamParticle: Particle;
|
|
||||||
networkId: string;
|
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||||
particleSegments: string[];
|
|
||||||
|
interface PlaybackState {
|
||||||
|
currentIndex: number;
|
||||||
|
status: PlaybackStatus;
|
||||||
|
paused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
|
type PlaybackAction =
|
||||||
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
|
| { 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 (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="relative flex h-full flex-col bg-black text-white">
|
||||||
<p className="text-muted-foreground text-sm">
|
{/* Progress indicator */}
|
||||||
Stream view — {networkId}/{particleSegments.join("/")}
|
<div className="z-10 absolute left-0 right-0">
|
||||||
</p>
|
<PlaybackPageIndicator
|
||||||
|
total={children.length}
|
||||||
|
current={state.currentIndex}
|
||||||
|
progress={progress}
|
||||||
|
onGoTo={goTo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
|
{/* Author overlay */}
|
||||||
{error && <p className="text-destructive text-sm">Failed to load stream data</p>}
|
{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">
|
||||||
{!isLoading && !error && (
|
<Avatar size="sm">
|
||||||
<div className="mt-4">
|
<AvatarFallback className="bg-white/20 text-[10px] font-medium text-white">
|
||||||
<p className="text-sm font-medium">Stream Children:</p>
|
{authorInitials}
|
||||||
<ul className="list-disc list-inside">
|
</AvatarFallback>
|
||||||
{children.map((child) => (
|
</Avatar>
|
||||||
<li key={child.id} className="text-sm">
|
<span className="text-xs text-white/70">
|
||||||
{child.id} ({child.type})
|
{authorEmail.split("@")[0]}
|
||||||
</li>
|
</span>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</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>
|
</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 type { Particle } from "@/api/types";
|
||||||
import { getParticleData } from "@/api/types";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -16,7 +15,7 @@ const TYPE_META: Record<string, { icon: typeof FileIcon; label: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface FallbackParticleViewProps {
|
interface FallbackParticleViewProps {
|
||||||
particle: StreamParticle;
|
particle: Particle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
|
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
|
||||||
@@ -28,13 +27,13 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
|
|||||||
const title = (() => {
|
const title = (() => {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case "quest":
|
case "quest":
|
||||||
return getParticleData(particle, "quest").title;
|
return particle.properties.title;
|
||||||
case "paper":
|
case "paper":
|
||||||
return getParticleData(particle, "paper").title;
|
return particle.properties.title;
|
||||||
case "file":
|
case "file":
|
||||||
return getParticleData(particle, "file").filename;
|
return particle.properties.filename;
|
||||||
case "folder":
|
case "folder":
|
||||||
return getParticleData(particle, "folder").name;
|
return particle.properties.name;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,37 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { MediaParticleData, StreamParticle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { apiClient } from "@/api/client";
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
|
||||||
import { useAudioSource } from "@/components/audio/use-audio-source";
|
import { useAudioSource } from "@/components/audio/use-audio-source";
|
||||||
|
|
||||||
|
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||||
|
|
||||||
interface MediaParticleViewProps {
|
interface MediaParticleViewProps {
|
||||||
particle: StreamParticle;
|
particle: MediaParticle;
|
||||||
}
|
paused: boolean;
|
||||||
|
onEnded: () => void;
|
||||||
function formatTime(ms: number): string {
|
onProgress?: (ratio: number) => void;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaParticleView({
|
export function MediaParticleView({
|
||||||
particle,
|
particle,
|
||||||
|
paused,
|
||||||
|
onEnded,
|
||||||
|
onProgress,
|
||||||
}: MediaParticleViewProps) {
|
}: MediaParticleViewProps) {
|
||||||
const cachedUrl = usePlaybackStore(
|
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
|
||||||
(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 videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(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;
|
// WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
|
||||||
const isAudio = data.mime_type?.startsWith("audio/");
|
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||||
|
const audioSource = useAudioSource(audioEl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedUrl) {
|
const el = isAudio ? audioRef.current : videoRef.current;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (paused) {
|
if (paused) {
|
||||||
@@ -90,17 +41,17 @@ export function MediaParticleView({
|
|||||||
console.warn("Playback failed", { particleId: particle.id });
|
console.warn("Playback failed", { particleId: particle.id });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [paused]);
|
}, [paused, isAudio, particle.id]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
||||||
{error}
|
Failed to load media
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cachedUrl) {
|
if (!url) {
|
||||||
return <Skeleton className="h-full w-full rounded-none" />;
|
return <Skeleton className="h-full w-full rounded-none" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,20 +59,25 @@ export function MediaParticleView({
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={(el) => {
|
||||||
|
audioRef.current = el;
|
||||||
|
setAudioEl(el);
|
||||||
|
}}
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
src={cachedUrl}
|
src={url}
|
||||||
autoPlay
|
autoPlay
|
||||||
onEnded={next}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={(e) => {
|
onTimeUpdate={(e) => {
|
||||||
setCurrentTimeMs(e.currentTarget.currentTime * 1000);
|
const { currentTime, duration } = e.currentTarget;
|
||||||
|
if (duration > 0) onProgress?.(currentTime / duration);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DurationPill
|
{audioSource && (
|
||||||
currentTimeMs={currentTimeMs}
|
<div className="z-10 absolute bottom-15">
|
||||||
totalDurationMs={data.duration_ms}
|
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||||
/>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,19 +86,16 @@ export function MediaParticleView({
|
|||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={cachedUrl}
|
src={url}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
onEnded={next}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={(e) => {
|
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"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<DurationPill
|
|
||||||
currentTimeMs={currentTimeMs}
|
|
||||||
totalDurationMs={data.duration_ms}
|
|
||||||
/>
|
|
||||||
</div>
|
</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 {
|
interface PlaybackPageIndicatorProps {
|
||||||
total: number;
|
total: number;
|
||||||
current: number;
|
current: number;
|
||||||
|
progress: number;
|
||||||
onGoTo: (index: number) => void;
|
onGoTo: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackPageIndicator({
|
export function PlaybackPageIndicator({
|
||||||
total,
|
total,
|
||||||
current,
|
current,
|
||||||
|
progress,
|
||||||
onGoTo,
|
onGoTo,
|
||||||
}: PlaybackPageIndicatorProps) {
|
}: PlaybackPageIndicatorProps) {
|
||||||
if (total === 0) return null;
|
if (total === 0) return null;
|
||||||
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
|
|||||||
}}
|
}}
|
||||||
className="group relative h-3 flex-1"
|
className="group relative h-3 flex-1"
|
||||||
>
|
>
|
||||||
{/* Track */}
|
{/* Dim track */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
|
"absolute inset-x-0 top-1 h-1 rounded-full bg-white/30",
|
||||||
i <= current ? "bg-white/90" : "bg-white/30",
|
|
||||||
"group-hover:h-1.5 group-hover:top-0.5",
|
"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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
|
|
||||||
interface TextParticleViewProps {
|
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) {
|
function getTextStyle(length: number) {
|
||||||
@@ -12,9 +29,37 @@ function getTextStyle(length: number) {
|
|||||||
return { size: "text-lg", weight: "font-normal" };
|
return { size: "text-lg", weight: "font-normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextParticleView({ particle }: TextParticleViewProps) {
|
export function TextParticleView({
|
||||||
const data = particle.data as TextParticleData;
|
particle,
|
||||||
const style = getTextStyle(data.content.length);
|
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 (
|
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">
|
<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,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.content}
|
{particle.properties.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const signOut = useAuthStore((s) => s.signOut);
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
@@ -68,7 +68,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<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 />
|
<WindowControls />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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 { 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 type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
||||||
|
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||||
|
|
||||||
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||||
collectionPath: string;
|
// Path to which the new particle will be added as a child
|
||||||
|
path: ParticlePath;
|
||||||
type: T;
|
type: T;
|
||||||
properties: ParticlePropertiesMap[T];
|
properties: ParticlePropertiesMap[T];
|
||||||
createdByEmail: string;
|
createdByEmail: string;
|
||||||
visibleTo: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateParticle() {
|
export function useCreateParticle() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (params: CreateParticleParams) =>
|
mutationFn: async (params: CreateParticleParams) => {
|
||||||
createParticle(
|
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||||
params.collectionPath,
|
const result = await createParticle(
|
||||||
|
collectionPath,
|
||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByEmail,
|
params.createdByEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
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(),
|
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 { useState, useEffect, useMemo } from "react";
|
||||||
import { subscribeToParticle } from "@/lib/firestore-particles";
|
import {
|
||||||
import { firestorePath } from "@/lib/firestore-paths";
|
subscribeToParticle,
|
||||||
|
subscribeToParticleChildren,
|
||||||
|
subscribeToLatestChild,
|
||||||
|
getParticle,
|
||||||
|
} from "@/lib/firestore-particles";
|
||||||
import type { Particle } from "@/api/types";
|
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;
|
particle: Particle | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useParticle(
|
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
|
||||||
networkId: string,
|
|
||||||
segments: string[],
|
|
||||||
): UseParticleResult {
|
|
||||||
const [particle, setParticle] = useState<Particle | null>(null);
|
const [particle, setParticle] = useState<Particle | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const path = useMemo(
|
const docPath = useMemo(() => toFirestoreDocPath(path), [path]);
|
||||||
() => firestorePath(networkId, segments),
|
|
||||||
[networkId, segments.join("/")],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -28,7 +32,7 @@ export function useParticle(
|
|||||||
setParticle(null);
|
setParticle(null);
|
||||||
|
|
||||||
const unsubscribe = subscribeToParticle(
|
const unsubscribe = subscribeToParticle(
|
||||||
path,
|
docPath,
|
||||||
(data) => {
|
(data) => {
|
||||||
setParticle(data);
|
setParticle(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -40,7 +44,96 @@ export function useParticle(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [path]);
|
}, [docPath]);
|
||||||
|
|
||||||
return { particle, isLoading, error };
|
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,
|
doc,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
addDoc,
|
addDoc,
|
||||||
|
getDoc,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
query,
|
query,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
limit,
|
||||||
serverTimestamp,
|
serverTimestamp,
|
||||||
|
where,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
type DocumentData,
|
type DocumentData,
|
||||||
type FirestoreDataConverter,
|
type FirestoreDataConverter,
|
||||||
@@ -15,7 +18,7 @@ import {
|
|||||||
type Unsubscribe,
|
type Unsubscribe,
|
||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { firestoreDb } from "@/firebase";
|
import { firestoreDb } from "@/firebase";
|
||||||
import { ParticleSchema } from "@/api/types";
|
import { isContainerType, ParticleSchema } from "@/api/types";
|
||||||
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
|
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
|
||||||
|
|
||||||
// --- Converter ---
|
// --- Converter ---
|
||||||
@@ -34,15 +37,56 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
options?: SnapshotOptions,
|
options?: SnapshotOptions,
|
||||||
): Particle {
|
): Particle {
|
||||||
const raw = snap.data(options);
|
const raw = snap.data(options);
|
||||||
return ParticleSchema.parse({
|
if (typeof raw.type !== "string") {
|
||||||
id: snap.id,
|
throw new Error(`Invalid particle type: ${raw.type}`);
|
||||||
type: raw.type,
|
}
|
||||||
properties: raw.properties,
|
const type = raw.type as ParticleType;
|
||||||
created_at: (raw.created_at as Timestamp).toDate(),
|
switch (type) {
|
||||||
created_by_email: raw.created_by_email,
|
case "stream":
|
||||||
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
|
return ParticleSchema.parse({
|
||||||
visible_to: raw.visible_to,
|
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(
|
export function subscribeToParticleChildren(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
onData: (children: Particle[]) => void,
|
onData: (children: Particle[]) => void,
|
||||||
onError: (error: Error) => void,
|
onError: (error: Error) => void,
|
||||||
|
visibilityScopes: string[] = [],
|
||||||
|
orderByField: string = "created_at",
|
||||||
|
orderDirection: "asc" | "desc" = "desc",
|
||||||
): Unsubscribe {
|
): 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(
|
return onSnapshot(
|
||||||
q,
|
q,
|
||||||
(snap) => {
|
(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>(
|
export async function createParticle<T extends ParticleType>(
|
||||||
collectionPath: string,
|
collectionPath: string,
|
||||||
type: T,
|
type: T,
|
||||||
properties: ParticlePropertiesMap[T],
|
properties: ParticlePropertiesMap[T],
|
||||||
createdByEmail: string,
|
createdByEmail: string,
|
||||||
visibleTo: string[],
|
// Must be passed for container types
|
||||||
|
visibleTo?: string[],
|
||||||
): Promise<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({
|
const particle: Particle = ParticleSchema.parse({
|
||||||
id: "", // ignored by toFirestore, but needed to satisfy the type
|
id: "", // ignored by toFirestore, but needed to satisfy the type
|
||||||
type,
|
type,
|
||||||
properties,
|
properties,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
created_by_email: createdByEmail,
|
created_by_email: createdByEmail,
|
||||||
updated_at: null,
|
...(visibleTo ? { visible_to: visibleTo } : {}),
|
||||||
visible_to: visibleTo,
|
|
||||||
});
|
});
|
||||||
const ref = await addDoc(typedCollection(collectionPath), particle);
|
const ref = await addDoc(typedCollection(collectionPath), particle);
|
||||||
return ref.id;
|
return ref.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This allows updating properties without overwriting the entire properties object
|
// 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,
|
docPath: string,
|
||||||
properties: Partial<ParticlePropertiesMap[T]>,
|
properties: Partial<ParticlePropertiesMap[T]>,
|
||||||
visibleTo?: string[],
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const particleRef = typedDoc(docPath);
|
const particleRef = typedDoc(docPath);
|
||||||
// Take the partial and create a new object with dot notation
|
// 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, {
|
await updateDoc(particleRef, {
|
||||||
...updatedProperties,
|
...updatedProperties,
|
||||||
updated_at: serverTimestamp(),
|
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();
|
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 = () => {
|
const createWindow = () => {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
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