diff --git a/go/internal/handler/handler.go b/go/internal/handler/handler.go index cb261a1..65f6dcb 100644 --- a/go/internal/handler/handler.go +++ b/go/internal/handler/handler.go @@ -447,7 +447,7 @@ func (h *Handler) RemoveMemberFromNetwork(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNoContent) } -// DownloadParticleMedia redirects to a fresh signed download URL for media/file particles +// DownloadParticleMedia returns a fresh signed download URL for media/file particles func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) { _, ok := middleware.EmailFromContext(r.Context()) if !ok { @@ -459,33 +459,6 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) objectID := r.PathValue("id") - // particleID := r.PathValue("id") - // if particleID == "" { - // http.Error(w, "object id is required", http.StatusBadRequest) - // return - // } - - // p, err := h.particleSvc.GetByID(r.Context(), particleID, email) - // if err != nil { - // if errors.Is(err, particle.ErrNotFound) { - // http.Error(w, "particle not found", http.StatusNotFound) - // return - // } - // if errors.Is(err, particle.ErrAccessDenied) { - // http.Error(w, "access denied", http.StatusForbidden) - // return - // } - // slog.Error("failed to get particle for download", "error", err, "particle_id", particleID, "email", email) - // http.Error(w, "internal server error", http.StatusInternalServerError) - // return - // } - // - // objectID := extractObjectID(p) - // if objectID == "" { - // http.Error(w, "particle has no downloadable content", http.StatusBadRequest) - // return - // } - downloadURL, err := h.depotSvc.GetDownloadURL(r.Context(), objectID) if err != nil { slog.Error("failed to get download URL", "error", err, "object_id", objectID) @@ -493,7 +466,8 @@ func (h *Handler) DownloadParticleMedia(w http.ResponseWriter, r *http.Request) return } - http.Redirect(w, r, downloadURL, http.StatusFound) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"url": downloadURL}) } // ============================================================================ diff --git a/js/assets/flowy.ico b/js/assets/flowy.ico new file mode 100644 index 0000000..f097dba Binary files /dev/null and b/js/assets/flowy.ico differ diff --git a/js/forge.config.ts b/js/forge.config.ts index 2b90693..ba80079 100644 --- a/js/forge.config.ts +++ b/js/forge.config.ts @@ -10,6 +10,7 @@ import { FuseV1Options, FuseVersion } from '@electron/fuses'; const config: ForgeConfig = { packagerConfig: { asar: true, + icon: './assets/flowy', }, rebuildConfig: {}, makers: [ diff --git a/js/package.json b/js/package.json index e7bc81a..beb4864 100644 --- a/js/package.json +++ b/js/package.json @@ -1,8 +1,8 @@ { - "name": "js", - "productName": "js", + "name": "llink", + "productName": "llink", "version": "1.0.0", - "description": "My Electron application description", + "description": "Flowy.llink is a team communication app for teams", "main": ".vite/build/main.js", "private": true, "scripts": { diff --git a/js/src/App.tsx b/js/src/App.tsx index 8638d5f..cdd788b 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -8,8 +8,11 @@ import { QueryClient, QueryClientProvider, } from '@tanstack/react-query' -import PathResolver from "./pages/path-resolver"; -import { SettingsPage } from "./pages/settings-page"; +import SettingsPage from "@/features/settings-page"; +import NetworkSelector from "@/features/network-selector"; +import NetworkRoot from "@/features/network-root"; +import ParticleViewResolver from "@/features/particles/particle-view-resolver"; +import Layout from "@/features/layout"; const queryClient = new QueryClient(); @@ -37,12 +40,18 @@ const App = () => { }; function AuthenticatedApp() { - // NOTE: Hash router provides history, despite using catch-all return ( - } /> - } /> + } /> + + }> + } /> + + } /> + } /> + + ); diff --git a/js/src/api/client.ts b/js/src/api/client.ts index d27afa0..9794b76 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -44,9 +44,11 @@ class ApiClient { path: string, body?: unknown, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = {}; + + if (body) { + headers["Content-Type"] = "application/json"; + } const token = this.config.getToken(); if (token) { @@ -115,7 +117,8 @@ class ApiClient { "GET", `/particles/${objectId}/download`, ); - return response.url; + const data = await response.json(); + return data.url; } // --- Depot --- diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 1583c24..17ae94e 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -128,14 +128,26 @@ const ParticleBaseSchema = z.object({ created_at: z.coerce.date(), created_by_email: z.string().email(), updated_at: z.coerce.date().optional(), - // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John - // e.g. ["network:123"] - visible to everyone in the network - visible_to: z.array(z.string()) }); export const ParticleSchema = z.discriminatedUnion("type", [ - ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }), - ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }), + ParticleBaseSchema.extend({ + type: z.literal("stream"), properties: StreamPropertiesSchema, + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()), + // Marks human_id to their `playback_position_at`: where they left off in a conversation + playback_markers: z.record(z.string(), z.coerce.date()).optional(), + // Timestamp of the most recent child particle + // used for sorting streams by recent activity without needing to query subcollections + last_child_created_at: z.coerce.date().optional(), + }), + ParticleBaseSchema.extend({ + type: z.literal("folder"), properties: FolderPropertiesSchema, + // e.g. ["human:aron@acme.com", "human:john@acme.com"] - visible only to Aron and John + // e.g. ["network:123"] - visible to everyone in the network + visible_to: z.array(z.string()), + }), ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }), ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }), diff --git a/js/src/components/ui/checkbox.tsx b/js/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cec7a77 --- /dev/null +++ b/js/src/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx new file mode 100644 index 0000000..8a312e7 --- /dev/null +++ b/js/src/features/compose/compose-overlay.tsx @@ -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("idle"); + const [error, setError] = useState(null); + const [textContent, setTextContent] = useState(""); + const [mediaStream, setMediaStream] = useState(null); + const [reviewBlob, setReviewBlob] = useState(null); + const [reviewDurationMs, setReviewDurationMs] = useState(0); + const [reviewMimeType, setReviewMimeType] = useState(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") && ( + + )} + {step === "typing" && ( + + )} + {!targetPath && step === "configuring" && ( + + )} + + ); +} diff --git a/js/src/features/compose/configure-stream-step.tsx b/js/src/features/compose/configure-stream-step.tsx new file mode 100644 index 0000000..c6bad18 --- /dev/null +++ b/js/src/features/compose/configure-stream-step.tsx @@ -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>(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 ( +
+
+ {/* Stream name */} +
+ + { + 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" + /> +
+ + {/* Visibility */} +
+ +
+ {/* Everyone in network */} +
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", + )} + > + setEveryone(checked === true)} + tabIndex={-1} + className="pointer-events-none" + /> + Everyone in network +
+ + {/* Per-member selection */} + {!everyone && members.length > 0 && ( + +
+ {members.map((member, index) => { + const isSelected = selectedEmails.has(member.email); + const initials = member.email_prefix + .slice(0, 2) + .toUpperCase(); + + return ( +
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", + )} + > + + + {initials} + + + {member.email_prefix} + +
+ ); + })} +
+
+ )} +
+
+
+ + {/* Keyboard hints */} +
+ + + Esc + {" "} + cancel + + + + ⌘+Enter + {" "} + create + +
+
+ ); +} diff --git a/js/src/features/compose/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx new file mode 100644 index 0000000..e46da0a --- /dev/null +++ b/js/src/features/compose/controls-indicator.tsx @@ -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 ( +
+ + {children ?
{children}
: +
+ } + {children && ·} +
+ Hold{" "} + + ` + {" "} + to {type === "reply" ? "reply " : "start "} + · Press{" "} + + T + {" "} + for text +
+
+ ); +} diff --git a/js/src/features/send/recording-overlay.tsx b/js/src/features/compose/recording-overlay.tsx similarity index 74% rename from js/src/features/send/recording-overlay.tsx rename to js/src/features/compose/recording-overlay.tsx index 9d8577e..9779ae3 100644 --- a/js/src/features/send/recording-overlay.tsx +++ b/js/src/features/compose/recording-overlay.tsx @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { useRecordingStore } from "@/stores/recording-store"; +import type { RecordingMode } from "@/hooks/use-recording-mode"; import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { useAudioSource } from "@/components/audio/use-audio-source"; interface RecordingOverlayProps { + step: "recording" | "reviewing"; + mediaStream: MediaStream | null; + recordingMode: RecordingMode; + reviewBlob: Blob | null; + error: string | null; onClose: () => void; } @@ -87,24 +92,17 @@ function ReviewPlayback({ ); } -export function RecordingOverlay({ onClose }: RecordingOverlayProps) { - const status = useRecordingStore((s) => s.status); - const mediaStream = useRecordingStore((s) => s.mediaStream); - const recordingMode = useRecordingStore((s) => s.recordingMode); - const reviewBlob = useRecordingStore((s) => s.reviewBlob); +export function RecordingOverlay({ + step, + mediaStream, + recordingMode, + reviewBlob, + error, + onClose, +}: RecordingOverlayProps) { const videoRef = useRef(null); - const hasBeenActiveRef = useRef(false); const recordingAudioSource = useAudioSource(mediaStream ?? null); - // Track whether we've entered an active state at least once - if ( - status === "recording" || - status === "uploading" || - status === "reviewing" - ) { - hasBeenActiveRef.current = true; - } - // Set video srcObject for live preview useEffect(() => { if (videoRef.current && mediaStream && recordingMode === "video") { @@ -112,26 +110,15 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { } }, [mediaStream, recordingMode]); - // Auto-close when status returns to idle after being active - useEffect(() => { - if (!hasBeenActiveRef.current) return; - if (status === "idle") { - onClose(); - } - }, [status, onClose]); - // Auto-close after error with a brief delay useEffect(() => { - if (status !== "error") return; + if (!error) return; const timeout = setTimeout(onClose, 1500); return () => clearTimeout(timeout); - }, [status, onClose]); + }, [error, onClose]); - const isUploading = status === "uploading"; - const isReviewing = status === "reviewing"; - const isRecording = status === "recording"; - - // Loading: status is recording but media stream hasn't arrived yet + const isReviewing = step === "reviewing"; + const isRecording = step === "recording"; const isLoading = isRecording && !mediaStream; return ( @@ -166,17 +153,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { /> )} - {/* Dimmed overlay when uploading */} - {isUploading &&
} - - {/* Top center: recording indicator / uploading */} + {/* Top center: recording indicator */}
- {isUploading ? ( -
- - Sending... -
- ) : isRecording && !isLoading ? ( + {isRecording && !isLoading ? ( ) : isReviewing ? (
@@ -185,9 +164,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { ) : null}
- {/* Center: audio level bars (recording with active stream) */} + {/* Bottom center: audio level bars (recording with active stream) */} {isRecording && recordingAudioSource && ( -
+
)} @@ -217,7 +196,7 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { Enter {" "} - to send + next @@ -229,9 +208,9 @@ export function RecordingOverlay({ onClose }: RecordingOverlayProps) { )} {/* Error state */} - {status === "error" && ( + {error && (
- {useRecordingStore.getState().error ?? "Recording failed"} + {error}
)}
diff --git a/js/src/features/send/text-compose-overlay.tsx b/js/src/features/compose/text-compose-step.tsx similarity index 56% rename from js/src/features/send/text-compose-overlay.tsx rename to js/src/features/compose/text-compose-step.tsx index 73365bf..c3fcfe8 100644 --- a/js/src/features/send/text-compose-overlay.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -1,11 +1,11 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; -import { apiClient } from "@/api/client"; -import { useAppStore } from "@/stores/app-store"; -interface TextComposeOverlayProps { - streamId: string; - onClose: () => void; +interface TextComposeStepProps { + textContent: string; + onTextChange: (text: string) => void; + onAdvance: () => void; + onCancel: () => void; } function getTextStyle(length: number) { @@ -15,61 +15,41 @@ function getTextStyle(length: number) { return { size: "text-lg", weight: "font-normal" }; } -export function TextComposeOverlay({ - streamId, - onClose, -}: TextComposeOverlayProps) { - const [content, setContent] = useState(""); - const [sending, setSending] = useState(false); +export function TextComposeStep({ + textContent, + onTextChange, + onAdvance, + onCancel, +}: TextComposeStepProps) { const textareaRef = useRef(null); - const addParticleToStream = useAppStore((s) => s.addParticleToStream); useEffect(() => { textareaRef.current?.focus(); }, []); - const handleSend = useCallback(async () => { - const trimmed = content.trim(); - if (!trimmed || sending) return; - - setSending(true); - try { - const particle = await apiClient.createStreamParticle(streamId, { - type: "text", - data: { content: trimmed }, - }); - addParticleToStream(streamId, particle); - onClose(); - } catch { - setSending(false); - } - }, [content, sending, streamId, addParticleToStream, onClose]); - const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); - e.stopPropagation(); - onClose(); + onCancel(); } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - handleSend(); + if (textContent.trim()) onAdvance(); } }, - [onClose, handleSend], + [onCancel, onAdvance, textContent], ); - const style = getTextStyle(content.length); + const style = getTextStyle(textContent.length); return (