feat: send screen recordings (#128)

* first pass implementation

* remove screenrecord shortcut hint

* refactor

* fix: prevent mirror review of screen recording

* feat: allow recording with webcam overlay

* fix: review screen recording without clipped content

* tidy keyboard hints consistent position

* refactor: re-use one component for screen picker

huddles and screen clips use same picker component now. We had to make
sure that tailwind works for both of them.

* fix: improve visibility of keyboard hints

During compose video or screen recording, the keyboard hints were
invisible if the content was super bright.
This commit was merged in pull request #128.
This commit is contained in:
Arjun Patel
2026-04-08 17:14:39 -07:00
committed by GitHub
parent bebd814c46
commit be7a42c483
18 changed files with 694 additions and 29 deletions
+94 -8
View File
@@ -3,9 +3,11 @@ import { toast } from "sonner";
import { useAuthStore } from "@/stores/auth-store";
import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle";
import { useRecorder } from "@/features/compose/use-recorder";
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
import { particlePath, parseParticlePath } from "@/lib/particle-path";
import type { ParticlePath } from "@/lib/particle-path";
import { RecordingOverlay } from "@/features/compose/recording-overlay";
import { ScreenSourcePicker } from "@/components/screen-source-picker";
import { TextComposeStep } from "@/features/compose/text-compose-step";
import { ConfigureStreamStep } from "@/features/compose/configure-stream-step";
import { apiClient } from "@/api/client";
@@ -15,7 +17,9 @@ import { createImageThumbnail } from "@/lib/image-thumbnail";
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants";
import type { PendingAttachment } from "@/features/compose/attachment-strip";
type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
type ComposeStep = "idle" | "picking" | "recording" | "reviewing" | "typing" | "configuring" | "submitting";
type RecordingSource = "media" | "screen";
interface ComposeOverlayProps {
networkId: string;
@@ -50,6 +54,7 @@ export function ComposeOverlay({
const [reviewMimeType, setReviewMimeType] = useState<string | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [recordingSource, setRecordingSource] = useState<RecordingSource>("media");
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const userId = useAuthStore((s) => s.user?.id);
@@ -61,6 +66,8 @@ export function ComposeOverlay({
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const recordingSourceRef = useRef(recordingSource);
recordingSourceRef.current = recordingSource;
const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next;
@@ -86,6 +93,7 @@ export function ComposeOverlay({
setReviewBlob(null);
setReviewDurationMs(0);
setReviewMimeType(null);
setRecordingSource("media");
setAttachments((prev) => {
revokeAttachmentThumbnails(prev);
return [];
@@ -151,6 +159,24 @@ export function ComposeOverlay({
onError: (message) => setError(message),
});
const {
startRecording: startScreenRecording,
stopRecording: stopScreenRecording,
cancelRecording: cancelScreenRecording,
} = useScreenRecorder({
mode: recordingMode,
onFinish: (blob, durationMs, mimeType) => {
setStepSync("reviewing");
setReviewBlob(blob);
setReviewDurationMs(durationMs);
setReviewMimeType(mimeType);
},
onError: (message) => {
setError(message);
cancel();
},
});
// --- Submission ---
const uploadMedia = useCallback(
@@ -259,6 +285,7 @@ export function ComposeOverlay({
reviewMimeType,
);
const isAudioOnly = reviewMimeType.startsWith("audio/");
particleId = await createParticle.mutateAsync({
path,
type: "media",
@@ -267,6 +294,9 @@ export function ComposeOverlay({
mime_type: reviewMimeType,
duration_ms: reviewDurationMs,
size_bytes,
...(!isAudioOnly && {
source: recordingSource === "screen" ? "screen" as const : "camera" as const,
}),
},
createdByHumanId: userId,
});
@@ -283,6 +313,7 @@ export function ComposeOverlay({
reviewBlob,
reviewMimeType,
reviewDurationMs,
recordingSource,
createParticle,
uploadMedia,
uploadAttachments,
@@ -327,7 +358,7 @@ export function ComposeOverlay({
const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current;
if (currentStep === "typing" || currentStep === "configuring") {
if (currentStep === "typing" || currentStep === "configuring" || currentStep === "picking") {
if (e.key === "Escape") {
e.preventDefault();
cancel();
@@ -347,7 +378,7 @@ export function ComposeOverlay({
switch (currentStep) {
case "idle": {
if (disabledRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T") {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") {
e.preventDefault();
toast.info("This stream is closed");
}
@@ -356,8 +387,13 @@ export function ComposeOverlay({
if (e.key === "`" && !e.repeat) {
e.preventDefault();
recordStartRef.current = Date.now();
setRecordingSource("media");
setStepSync("recording");
startRecording();
} else if (e.key === "s" || e.key === "S") {
e.preventDefault();
setRecordingSource("screen");
setStepSync("picking");
} else if (e.key === "t" || e.key === "T") {
e.preventDefault();
setStepSync("typing");
@@ -370,9 +406,17 @@ export function ComposeOverlay({
// Second tap stops recording (toggle mode)
e.preventDefault();
stopRecording();
} else if ((e.key === "s" || e.key === "S") && recordingSourceRef.current === "screen") {
// S stops screen recording when main window is focused
e.preventDefault();
stopScreenRecording();
} else if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
}
break;
@@ -381,7 +425,11 @@ export function ComposeOverlay({
case "reviewing": {
if (e.key === "q" || e.key === "Q" || e.key === "Escape") {
e.preventDefault();
cancelRecording();
if (recordingSourceRef.current === "screen") {
cancelScreenRecording();
} else {
cancelRecording();
}
cancel();
} else if (e.key === "Enter") {
e.preventDefault();
@@ -397,7 +445,7 @@ export function ComposeOverlay({
};
const handleKeyUp = (e: KeyboardEvent) => {
if (stepRef.current === "recording" && e.key === "`") {
if (stepRef.current === "recording" && e.key === "`" && recordingSourceRef.current === "media") {
e.preventDefault();
// Only stop on release if held long enough (hold-to-record mode).
// Quick taps are handled by the second keydown (toggle mode).
@@ -413,7 +461,17 @@ export function ComposeOverlay({
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [targetPath, startRecording, stopRecording, cancelRecording, cancel, setStepSync]);
}, [targetPath, startRecording, stopRecording, cancelRecording, startScreenRecording, stopScreenRecording, cancelScreenRecording, cancel, setStepSync]);
// --- Screen source selection handler ---
const handleScreenSourceSelected = useCallback(
(sourceId: string) => {
setStepSync("recording");
startScreenRecording(sourceId);
},
[setStepSync, startScreenRecording],
);
// --- Render ---
@@ -425,7 +483,16 @@ export function ComposeOverlay({
return (
<>
{(step === "recording" || step === "reviewing") && (
{step === "picking" && (
<ScreenSourcePicker
title="Record your screen"
confirmLabel="Record"
getSources={window.electronScreen.getScreenSources}
onSelect={handleScreenSourceSelected}
onCancel={cancel}
/>
)}
{(step === "recording" || step === "reviewing") && recordingSource === "media" && (
<RecordingOverlay
step={step}
mediaStream={mediaStream}
@@ -438,6 +505,25 @@ export function ComposeOverlay({
onAddFiles={openFilePicker}
isDragging={isDragging}
dropZoneProps={dropZoneProps}
mirror={true}
objectFit="cover"
/>
)}
{step === "reviewing" && recordingSource === "screen" && reviewBlob && (
<RecordingOverlay
step="reviewing"
mediaStream={null}
recordingMode="video"
reviewBlob={reviewBlob}
error={error}
onClose={cancel}
attachments={attachments}
onRemoveAttachment={removeAttachment}
onAddFiles={openFilePicker}
isDragging={isDragging}
dropZoneProps={dropZoneProps}
mirror={false}
objectFit="contain"
/>
)}
{step === "typing" && (