502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Pressable, Text, View } from "react-native";
|
|
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
|
|
import * as Haptics from "expo-haptics";
|
|
import { setAudioModeAsync } from "expo-audio";
|
|
import {
|
|
useCameraPermissions,
|
|
useMicrophonePermissions,
|
|
} from "expo-camera";
|
|
import {
|
|
Gesture,
|
|
GestureDetector,
|
|
} from "react-native-gesture-handler";
|
|
import Animated, {
|
|
Easing,
|
|
runOnJS,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import { toast } from "sonner-native";
|
|
import { cn } from "@/lib/utils";
|
|
import { useEvent } from "@/hooks/use-event";
|
|
import { logError } from "@/lib/errors";
|
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import {
|
|
createTextParticle,
|
|
uploadMediaParticle,
|
|
} from "@/lib/upload";
|
|
import type { ParticlePath } from "@/lib/particle-path";
|
|
import {
|
|
useStreamComposingBroadcast,
|
|
type ComposingMode,
|
|
} from "@/features/stream-view/stream-presence-context";
|
|
import { TextComposeModal } from "./TextComposeModal";
|
|
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
|
|
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
|
|
import { ReviewSheet } from "./ReviewSheet";
|
|
|
|
type RecordingMode = "video" | "audio";
|
|
type PendingAction = "commit" | "cancel" | null;
|
|
|
|
type ComposeUiState =
|
|
| { kind: "idle" }
|
|
| { kind: "warming-up"; mode: RecordingMode }
|
|
| { kind: "recording"; mode: RecordingMode }
|
|
| {
|
|
kind: "review";
|
|
mode: RecordingMode;
|
|
uri: string;
|
|
durationMs: number;
|
|
}
|
|
| { kind: "uploading" };
|
|
|
|
const CANCEL_THRESHOLD_PX = 120;
|
|
const SHORT_RELEASE_MS = 350;
|
|
|
|
interface SubmitMediaParams {
|
|
fileUri: string;
|
|
mimeType: string;
|
|
durationMs: number;
|
|
source: "camera" | "screen";
|
|
}
|
|
|
|
interface ComposeDockProps {
|
|
networkId: string;
|
|
/** Where new particles are created. Defaults to the current stream. */
|
|
targetPath: ParticlePath;
|
|
/**
|
|
* When true, the dock won't broadcast composing state — used by the
|
|
* new-stream screen where there's no presence channel to broadcast to.
|
|
*/
|
|
silentPresence?: boolean;
|
|
/**
|
|
* Optional: replace the default media upload logic. NewStreamScreen uses
|
|
* this to create the stream + first particle in one shot. The dock still
|
|
* owns the review-sheet / haptic / state-machine flow; only the final
|
|
* persistence step is swapped.
|
|
*/
|
|
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
|
/** Optional: replace the default text-particle creation. */
|
|
submitText?: (content: string) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Bottom dock + recording orchestrator. The hold-to-record gesture lives on
|
|
* the FAB and drives every state transition. `cancelProgress` is a worklet
|
|
* shared value the overlays read for live cancel-pill animations. Playback
|
|
* is suspended via the pause-store while the dock is anything but idle.
|
|
*/
|
|
export function ComposeDock({
|
|
networkId,
|
|
targetPath,
|
|
silentPresence = false,
|
|
submitMedia,
|
|
submitText: submitTextOverride,
|
|
}: ComposeDockProps) {
|
|
const userId = useAuthStore((s) => s.user?.id);
|
|
|
|
const [mode, setMode] = useState<RecordingMode>("video");
|
|
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
|
|
const [textOpen, setTextOpen] = useState(false);
|
|
// Both overlays read this prop to know how to finalize. It's set on release
|
|
// and cleared as the overlay unmounts.
|
|
const [pendingAction, setPendingAction] = useState<PendingAction>(null);
|
|
|
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
|
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
|
|
|
// Suspend underlying playback whenever the dock isn't idle. Mirrors how
|
|
// desktop's compose-overlay flips the playback-pause-store.
|
|
const isComposing = ui.kind !== "idle" || textOpen;
|
|
useSuspendPlayback(isComposing, "compose");
|
|
|
|
// Broadcast composing state over Pusher so other viewers see the dot.
|
|
// When silentPresence is set we skip — the dock renders outside a stream
|
|
// (e.g. NewStreamScreen) where no presence channel exists yet.
|
|
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
|
|
|
// Worklet-side flag the overlays read for live cancel UI.
|
|
const cancelProgress = useSharedValue(0);
|
|
// Press start time, kept on a ref so worklets can read it via runOnJS.
|
|
const pressStartedAtRef = useRef<number | null>(null);
|
|
|
|
const ensurePermissions = useCallback(
|
|
async (forVideo: boolean): Promise<boolean> => {
|
|
if (forVideo) {
|
|
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
|
|
if (!cam.granted) {
|
|
toast.error("Camera permission is required to record video.");
|
|
return false;
|
|
}
|
|
}
|
|
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
|
|
if (!mic.granted) {
|
|
toast.error("Microphone permission is required to record.");
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
[camPerm, micPerm, requestCamPerm, requestMicPerm],
|
|
);
|
|
|
|
// ----- Lifecycle handlers -----
|
|
|
|
const beginRecording = useEvent(async () => {
|
|
if (ui.kind !== "idle") return;
|
|
pressStartedAtRef.current = Date.now();
|
|
const ok = await ensurePermissions(mode === "video");
|
|
if (!ok) {
|
|
pressStartedAtRef.current = null;
|
|
return;
|
|
}
|
|
// iOS requires the audio session to allow recording before either the
|
|
// audio recorder or the camera's audio track will work.
|
|
try {
|
|
await setAudioModeAsync({
|
|
allowsRecording: true,
|
|
playsInSilentMode: true,
|
|
});
|
|
} catch (err) {
|
|
logError(err, { scope: "compose.audio.setMode.record" });
|
|
}
|
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
setUi({ kind: "warming-up", mode });
|
|
});
|
|
|
|
// Restore the audio session to playback-only when we leave recording,
|
|
// otherwise iOS keeps routing playback through the earpiece.
|
|
const restoreAudioMode = useCallback(() => {
|
|
setAudioModeAsync({
|
|
allowsRecording: false,
|
|
playsInSilentMode: true,
|
|
}).catch((err) =>
|
|
logError(err, { scope: "compose.audio.setMode.restore" }),
|
|
);
|
|
}, []);
|
|
|
|
const handleRecordingStarted = useCallback(() => {
|
|
setUi((prev) =>
|
|
prev.kind === "warming-up"
|
|
? { kind: "recording", mode: prev.mode }
|
|
: prev,
|
|
);
|
|
}, []);
|
|
|
|
const handleRecordingComplete = useCallback(
|
|
({ uri, durationMs }: { uri: string; durationMs: number }) => {
|
|
void Haptics.selectionAsync();
|
|
setUi((prev) => {
|
|
const m = "mode" in prev ? prev.mode : mode;
|
|
return { kind: "review", mode: m, uri, durationMs };
|
|
});
|
|
setPendingAction(null);
|
|
pressStartedAtRef.current = null;
|
|
restoreAudioMode();
|
|
},
|
|
[mode, restoreAudioMode],
|
|
);
|
|
|
|
const handleRecordingCancel = useCallback(() => {
|
|
setUi({ kind: "idle" });
|
|
setPendingAction(null);
|
|
pressStartedAtRef.current = null;
|
|
cancelProgress.value = 0;
|
|
restoreAudioMode();
|
|
}, [cancelProgress, restoreAudioMode]);
|
|
|
|
const finalizeFromGesture = useEvent(({ cancelled }: { cancelled: boolean }) => {
|
|
const heldMs = pressStartedAtRef.current
|
|
? Date.now() - pressStartedAtRef.current
|
|
: 0;
|
|
|
|
// Recording never warmed up to "recording" — drop back to idle without
|
|
// shipping anything (permission denied, or mount race).
|
|
if (ui.kind === "warming-up") {
|
|
setUi({ kind: "idle" });
|
|
pressStartedAtRef.current = null;
|
|
cancelProgress.value = 0;
|
|
restoreAudioMode();
|
|
return;
|
|
}
|
|
if (ui.kind !== "recording") return;
|
|
|
|
const wantCancel = cancelled || heldMs < SHORT_RELEASE_MS;
|
|
if (wantCancel) {
|
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
}
|
|
setPendingAction(wantCancel ? "cancel" : "commit");
|
|
cancelProgress.value = 0;
|
|
});
|
|
|
|
// ----- Gesture: hold-to-record on FAB -----
|
|
|
|
const pan = Gesture.Pan()
|
|
.minDistance(0)
|
|
.activateAfterLongPress(120)
|
|
.onStart(() => {
|
|
"worklet";
|
|
runOnJS(beginRecording)();
|
|
})
|
|
.onUpdate((e) => {
|
|
"worklet";
|
|
const upPx = Math.max(0, -e.translationY);
|
|
cancelProgress.value = Math.min(upPx / CANCEL_THRESHOLD_PX, 1);
|
|
})
|
|
.onEnd((e) => {
|
|
"worklet";
|
|
const cancelled = e.translationY < -CANCEL_THRESHOLD_PX;
|
|
runOnJS(finalizeFromGesture)({ cancelled });
|
|
})
|
|
.onFinalize(() => {
|
|
"worklet";
|
|
cancelProgress.value = 0;
|
|
});
|
|
|
|
// ----- Submit handlers -----
|
|
|
|
const sendReview = useEvent(async () => {
|
|
if (ui.kind !== "review" || !userId) return;
|
|
const captured = ui;
|
|
setUi({ kind: "uploading" });
|
|
try {
|
|
const mimeType =
|
|
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
|
|
if (submitMedia) {
|
|
await submitMedia({
|
|
fileUri: captured.uri,
|
|
mimeType,
|
|
durationMs: captured.durationMs,
|
|
source: "camera",
|
|
});
|
|
} else {
|
|
await uploadMediaParticle({
|
|
networkId,
|
|
targetPath,
|
|
fileUri: captured.uri,
|
|
mimeType,
|
|
durationMs: captured.durationMs,
|
|
source: "camera",
|
|
createdByHumanId: userId,
|
|
});
|
|
}
|
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
setUi({ kind: "idle" });
|
|
} catch (err) {
|
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
// Restore the review sheet so the user can retry without losing their
|
|
// take. The sheet's own toast already surfaced the error message.
|
|
setUi(captured);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const retake = useCallback(() => setUi({ kind: "idle" }), []);
|
|
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
|
|
|
|
const submitText = useEvent(async (content: string) => {
|
|
if (!userId) throw new Error("Not signed in.");
|
|
if (submitTextOverride) {
|
|
await submitTextOverride(content);
|
|
} else {
|
|
await createTextParticle({
|
|
networkId,
|
|
targetPath,
|
|
content,
|
|
createdByHumanId: userId,
|
|
});
|
|
}
|
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
});
|
|
|
|
// ----- FAB animations -----
|
|
|
|
const fabScale = useSharedValue(1);
|
|
useEffect(() => {
|
|
const target = ui.kind === "recording" ? 1.18 : 1;
|
|
fabScale.value = withTiming(target, {
|
|
duration: 220,
|
|
easing: Easing.out(Easing.cubic),
|
|
});
|
|
}, [ui.kind, fabScale]);
|
|
|
|
const fabStyle = useAnimatedStyle(() => ({
|
|
transform: [{ scale: fabScale.value }],
|
|
}));
|
|
|
|
const ringStyle = useAnimatedStyle(() => {
|
|
const opacity = ui.kind === "recording" ? 1 : 0;
|
|
const armed = cancelProgress.value > 0.5;
|
|
return {
|
|
opacity,
|
|
borderColor: armed
|
|
? "rgba(239, 68, 68, 0.95)"
|
|
: "rgba(255,255,255,0.95)",
|
|
};
|
|
});
|
|
|
|
// ----- Render -----
|
|
|
|
const dockHidden = ui.kind === "review" || ui.kind === "uploading";
|
|
|
|
return (
|
|
<>
|
|
{!dockHidden ? (
|
|
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
|
<View
|
|
pointerEvents="box-none"
|
|
className="flex-row items-center justify-between px-8 pb-10"
|
|
>
|
|
<Pressable
|
|
onPress={() =>
|
|
setMode((m) => (m === "video" ? "audio" : "video"))
|
|
}
|
|
disabled={ui.kind !== "idle"}
|
|
accessibilityLabel={`Switch to ${
|
|
mode === "video" ? "audio" : "video"
|
|
} mode`}
|
|
className={cn(
|
|
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
|
ui.kind !== "idle" && "opacity-40",
|
|
)}
|
|
>
|
|
{mode === "video" ? (
|
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
|
) : (
|
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
|
)}
|
|
</Pressable>
|
|
|
|
<View className="items-center">
|
|
<GestureDetector gesture={pan}>
|
|
<Animated.View
|
|
style={fabStyle}
|
|
className="h-20 w-20 items-center justify-center rounded-full bg-white"
|
|
>
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[
|
|
{
|
|
position: "absolute",
|
|
top: -6,
|
|
left: -6,
|
|
right: -6,
|
|
bottom: -6,
|
|
borderRadius: 999,
|
|
borderWidth: 3,
|
|
},
|
|
ringStyle,
|
|
]}
|
|
/>
|
|
<View
|
|
className={cn(
|
|
"h-6 w-6 rounded",
|
|
ui.kind === "recording" ? "bg-red-500" : "bg-black",
|
|
)}
|
|
/>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
<Text className="text-white/60 mt-2 text-xs">
|
|
{ui.kind === "recording"
|
|
? "Release to send · slide up to cancel"
|
|
: "Hold to record"}
|
|
</Text>
|
|
</View>
|
|
|
|
<Pressable
|
|
onPress={() => setTextOpen(true)}
|
|
disabled={ui.kind !== "idle"}
|
|
accessibilityLabel="Compose text"
|
|
className={cn(
|
|
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
|
|
ui.kind !== "idle" && "opacity-40",
|
|
)}
|
|
>
|
|
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{ui.kind === "warming-up" || ui.kind === "recording" ? (
|
|
ui.mode === "video" ? (
|
|
<VideoRecordingOverlay
|
|
cancelProgress={cancelProgress}
|
|
onComplete={handleRecordingComplete}
|
|
onCancel={handleRecordingCancel}
|
|
onRecordingStarted={handleRecordingStarted}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
) : (
|
|
<AudioRecordingOverlay
|
|
cancelProgress={cancelProgress}
|
|
onComplete={handleRecordingComplete}
|
|
onCancel={handleRecordingCancel}
|
|
onRecordingStarted={handleRecordingStarted}
|
|
pendingAction={pendingAction}
|
|
/>
|
|
)
|
|
) : null}
|
|
|
|
<ReviewSheet
|
|
open={ui.kind === "review"}
|
|
uri={ui.kind === "review" ? ui.uri : null}
|
|
mode={ui.kind === "review" ? ui.mode : null}
|
|
durationMs={ui.kind === "review" ? ui.durationMs : 0}
|
|
onSend={sendReview}
|
|
onRetake={retake}
|
|
onCancel={cancelReview}
|
|
/>
|
|
|
|
<TextComposeModal
|
|
open={textOpen}
|
|
onClose={() => setTextOpen(false)}
|
|
onSubmit={submitText}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reads the dock UI state and tells the StreamPresenceContext when to start
|
|
* or stop broadcasting composing_start/composing_stop. The hook intentionally
|
|
* uses optional context access so the dock can also be hosted on the
|
|
* new-stream screen, where there's no provider above us.
|
|
*/
|
|
function useComposingBroadcast({
|
|
ui,
|
|
textOpen,
|
|
silent,
|
|
}: {
|
|
ui: ComposeUiState;
|
|
textOpen: boolean;
|
|
silent: boolean;
|
|
}) {
|
|
// Optional consumer — `useStreamComposingBroadcast` throws when used
|
|
// outside its provider, so we trap that here. Cheap because the throw
|
|
// happens once per mount.
|
|
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
|
|
try {
|
|
broadcast = useStreamComposingBroadcast();
|
|
} catch {
|
|
broadcast = null;
|
|
}
|
|
|
|
const mode: ComposingMode | null =
|
|
ui.kind === "recording" || ui.kind === "warming-up"
|
|
? "recording"
|
|
: textOpen
|
|
? "typing"
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (silent || !broadcast) return;
|
|
if (mode) {
|
|
broadcast.startComposing(mode);
|
|
return () => broadcast?.stopComposing();
|
|
}
|
|
}, [mode, silent, broadcast]);
|
|
}
|