step 5-6: compose experience

This commit is contained in:
talksik
2026-04-29 13:29:02 -07:00
parent ddfcf1ee12
commit a974fa50b0
46 changed files with 3305 additions and 75 deletions
+5
View File
@@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({
size_bytes: z.number(),
transcript: TranscriptSchema.optional(),
source: z.enum(["camera", "screen"]).optional(),
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
// When present, clients should prefer these over object_id/mime_type for playback.
transcoded_object_id: z.string().optional(),
transcoded_mime_type: z.string().optional(),
});
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
@@ -33,12 +33,18 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
onEnded,
onProgress,
}, ref) {
const { data: url, error } = useDownloadUrl(particle.properties.object_id);
// Prefer the worker-produced iOS-playable variant when present so desktop
// and mobile read the same canonical asset. Falls back to the original.
const activeObjectId =
particle.properties.transcoded_object_id ?? particle.properties.object_id;
const activeMime =
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
const { data: url, error } = useDownloadUrl(activeObjectId);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const isAudio = particle.properties.mime_type?.startsWith("audio/");
const isAudio = activeMime?.startsWith("audio/");
useImperativeHandle(ref, () => ({
seek(deltaSec: number) {
+17 -5
View File
@@ -12,7 +12,7 @@ const config: ExpoConfig = {
userInterfaceStyle: "automatic",
newArchEnabled: true,
splash: {
image: "./assets/splash.png",
image: "./assets/icon.png",
resizeMode: "contain",
backgroundColor: "#000000",
},
@@ -20,10 +20,6 @@ const config: ExpoConfig = {
supportsTablet: true,
bundleIdentifier: "com.llink.flowy",
infoPlist: {
NSCameraUsageDescription:
"Flowy uses your camera to record video messages.",
NSMicrophoneUsageDescription:
"Flowy uses your microphone to record voice and video messages.",
ITSAppUsesNonExemptEncryption: false,
},
},
@@ -37,6 +33,22 @@ const config: ExpoConfig = {
},
],
"expo-secure-store",
[
"expo-camera",
{
cameraPermission:
"Flowy uses your camera to record video messages.",
microphonePermission:
"Flowy uses your microphone to record voice and video messages.",
},
],
[
"expo-audio",
{
microphonePermission:
"Flowy uses your microphone to record voice messages.",
},
],
],
experiments: {
typedRoutes: false,
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.
Binary file not shown.
+4 -1
View File
@@ -5,7 +5,7 @@
"main": "index.ts",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"ios": "expo run:ios --device",
"android": "expo run:android",
"compile": "tsc --noEmit",
"lint": "expo lint"
@@ -18,7 +18,10 @@
"@tanstack/react-query": "^5.90.21",
"clsx": "^2.1.1",
"expo": "~54.0.0",
"expo-audio": "~1.0.13",
"expo-camera": "~17.0.8",
"expo-constants": "~18.0.13",
"expo-file-system": "~19.0.16",
"expo-haptics": "~15.0.7",
"expo-secure-store": "~15.0.8",
"expo-video": "~3.0.10",
+10 -7
View File
@@ -6,6 +6,7 @@ import { NavigationContainer } from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { Toaster } from "sonner-native";
import { createQueryClient } from "@/lib/query-client";
import { PusherProvider } from "@/lib/pusher-provider";
import { RootNavigator } from "@/navigation/RootNavigator";
import { useAuthStore } from "@/stores/auth-store";
@@ -21,13 +22,15 @@ export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
<Toaster />
<StatusBar style="auto" />
</SafeAreaProvider>
<PusherProvider>
<SafeAreaProvider>
<NavigationContainer>
<RootNavigator />
</NavigationContainer>
<Toaster />
<StatusBar style="auto" />
</SafeAreaProvider>
</PusherProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
+5
View File
@@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({
size_bytes: z.number(),
transcript: TranscriptSchema.optional(),
source: z.enum(["camera", "screen"]).optional(),
// Set by the particle processor worker once an iOS-playable MP4/m4a variant
// has been produced from a non-iOS-playable original (e.g. WebM from desktop).
// When present, clients should prefer these over object_id/mime_type for playback.
transcoded_object_id: z.string().optional(),
transcoded_mime_type: z.string().optional(),
});
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
@@ -0,0 +1,95 @@
import { useEffect } from "react";
import { Text, View } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import type { ComposingUser } from "@/features/stream-view/stream-presence-context";
interface ComposingIndicatorProps {
users: ComposingUser[];
networkHumans?: Human[];
}
/**
* Slim horizontal pill stack pinned just under the metadata header. Each
* pill is the typing/recording indicator for a single user. Rendered above
* the particle canvas with a translucent background so it reads on any
* media. Mobile equivalent of desktop's vertical writing-mode indicator.
*/
export function ComposingIndicator({
users,
networkHumans,
}: ComposingIndicatorProps) {
if (users.length === 0) return null;
return (
<View pointerEvents="none" className="flex-row flex-wrap items-center gap-1.5">
{users.map((u) => {
const { displayName } = resolveHumanDisplay(u.humanId, networkHumans);
const label =
u.mode === "recording"
? `${displayName} is recording`
: u.mode === "screen"
? `${displayName} is sharing`
: `${displayName} is typing`;
return (
<View
key={u.humanId}
className="bg-white/15 flex-row items-center gap-1.5 rounded-full px-2 py-1"
>
<BouncingDots />
<Text className="text-white/80 text-[11px] font-medium">
{label}
</Text>
</View>
);
})}
</View>
);
}
function BouncingDots() {
return (
<View className="flex-row items-end gap-0.5" style={{ height: 8 }}>
<Dot delay={0} />
<Dot delay={150} />
<Dot delay={300} />
</View>
);
}
function Dot({ delay }: { delay: number }) {
const y = useSharedValue(0);
useEffect(() => {
const start = setTimeout(() => {
y.value = withRepeat(
withTiming(-3, {
duration: 360,
easing: Easing.inOut(Easing.quad),
}),
-1,
true,
);
}, delay);
return () => clearTimeout(start);
}, [delay, y]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: y.value }],
}));
return (
<Animated.View
className="bg-white/85 h-1 w-1 rounded-full"
style={style}
/>
);
}
@@ -0,0 +1,193 @@
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Mic } from "lucide-react-native";
import {
RecordingPresets,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useEvent } from "@/hooks/use-event";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface AudioRecordingOverlayProps {
cancelProgress: { value: number };
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
onRecordingStarted: () => void;
/**
* The parent owns the gesture that drives commit/cancel. To avoid race
* conditions when the user releases right as the overlay is mounting, we
* expose a `pendingAction` prop that's flushed once recording is live.
*/
pendingAction: "commit" | "cancel" | null;
}
/**
* Voice-only recording. Mounts an `expo-audio` recorder, kicks it off as soon
* as `prepareToRecordAsync` resolves, and waits for the parent to flush a
* commit/cancel action. The shared `cancelProgress` value lights the cancel
* hint as the user drags up.
*/
export function AudioRecordingOverlay({
cancelProgress,
onComplete,
onCancel,
onRecordingStarted,
pendingAction,
}: AudioRecordingOverlayProps) {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const recorderState = useAudioRecorderState(recorder, 100);
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const finalizedRef = useRef<"pending" | "completed" | "cancelled">(
"pending",
);
const onCompleteStable = useEvent(onComplete);
const onCancelStable = useEvent(onCancel);
const onStartedStable = useEvent(onRecordingStarted);
// Spin up the recorder.
useEffect(() => {
let cancelled = false;
(async () => {
try {
await recorder.prepareToRecordAsync();
if (cancelled) return;
recorder.record();
startedAtRef.current = Date.now();
onStartedStable();
} catch (err) {
logError(err, { scope: "compose.audio.prepare" });
if (!cancelled) onCancelStable();
}
})();
return () => {
cancelled = true;
};
// recorder identity is stable for the component's lifetime
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cap at 60s — match the desktop limit and the PRD.
useEffect(() => {
const interval = setInterval(() => {
if (startedAtRef.current === null) return;
const elapsed = Date.now() - startedAtRef.current;
setElapsedMs(elapsed);
if (elapsed >= MAX_DURATION_S * 1000) {
void finalize("commit");
}
}, 100);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const finalize = useEvent(async (kind: "commit" | "cancel") => {
if (finalizedRef.current !== "pending") return;
finalizedRef.current = kind === "commit" ? "completed" : "cancelled";
try {
await recorder.stop();
} catch (err) {
logError(err, { scope: "compose.audio.stop" });
}
if (kind === "cancel") {
onCancelStable();
return;
}
const uri = recorder.uri;
if (!uri) {
onCancelStable();
return;
}
const durationMs =
startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0;
onCompleteStable({ uri, durationMs });
});
// Flush parent-issued action when it arrives.
useEffect(() => {
if (pendingAction === "commit") void finalize("commit");
if (pendingAction === "cancel") void finalize("cancel");
}, [pendingAction, finalize]);
// Safety: stop recording on unmount if we never finalized.
useEffect(() => {
return () => {
if (finalizedRef.current === "pending") {
finalizedRef.current = "cancelled";
recorder.stop().catch(() => {});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const elapsedSec = Math.floor(elapsedMs / 1000);
const isRecording = recorderState.isRecording;
return (
<View
style={StyleSheet.absoluteFill}
className="bg-black items-center justify-center px-8"
>
<PulsingMic active={isRecording} />
<Text className="text-white mt-6 text-lg font-semibold">
Voice message
</Text>
<Text className="text-white/60 mt-1 text-sm">
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
</Text>
<CancelHint cancelProgress={cancelProgress} />
</View>
);
}
function PulsingMic({ active }: { active: boolean }) {
const scale = useSharedValue(1);
useEffect(() => {
if (!active) return;
scale.value = withRepeat(
withTiming(1.15, { duration: 700, easing: Easing.inOut(Easing.quad) }),
-1,
true,
);
}, [active, scale]);
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Animated.View
style={style}
className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full"
>
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} />
</View>
</Animated.View>
);
}
function CancelHint({
cancelProgress,
}: {
cancelProgress: { value: number };
}) {
const style = useAnimatedStyle(() => {
const progress = Math.max(0, Math.min(cancelProgress.value, 1));
return { opacity: 0.5 + progress * 0.5 };
});
return (
<Animated.View style={style} className="absolute bottom-32">
<Text className="text-white/70 text-sm">Slide up to cancel</Text>
</Animated.View>
);
}
@@ -0,0 +1,501 @@
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]);
}
@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useVideoPlayer, VideoView } from "expo-video";
import { Mic } from "lucide-react-native";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
interface ReviewSheetProps {
open: boolean;
/** Local file URI from the recorder. */
uri: string | null;
mode: "video" | "audio" | null;
durationMs: number;
onSend: () => Promise<void>;
onRetake: () => void;
onCancel: () => void;
}
/**
* Loop-plays the just-recorded clip and offers Retake / Send. WhatsApp-style
* confirmation: any send-failure surfaces a toast and keeps the sheet open
* so the user doesn't lose their take.
*/
export function ReviewSheet({
open,
uri,
mode,
durationMs,
onSend,
onRetake,
onCancel,
}: ReviewSheetProps) {
const player = useVideoPlayer(uri ?? "", (p) => {
p.loop = true;
p.muted = false;
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open && uri) {
player.play();
}
}, [open, uri, player]);
const handleSend = async () => {
if (submitting) return;
setSubmitting(true);
try {
await onSend();
} catch (err) {
toast.error(toUserMessage(err));
setSubmitting(false);
}
};
const seconds = Math.max(1, Math.round(durationMs / 1000));
return (
<Modal
visible={open}
animationType="fade"
transparent={false}
statusBarTranslucent
onRequestClose={onCancel}
>
<View className="flex-1 bg-black">
{uri ? (
mode === "audio" ? (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} />
</View>
<Text className="text-white mt-6 text-lg font-semibold">
Voice message · {seconds}s
</Text>
<Text className="text-white/50 mt-2 text-sm">
Tap send to share, or retake.
</Text>
<View className="absolute" style={{ width: 1, height: 1 }}>
<VideoView
style={{ width: 1, height: 1 }}
player={player}
nativeControls={false}
/>
</View>
</View>
) : (
<VideoView
style={{ flex: 1 }}
player={player}
nativeControls={false}
contentFit="cover"
allowsFullscreen={false}
allowsPictureInPicture={false}
/>
)
) : null}
<SafeAreaView
edges={["top"]}
className="absolute top-0 left-0 right-0"
>
<View className="px-4 pt-3">
<Pressable
onPress={onCancel}
hitSlop={12}
accessibilityLabel="Cancel"
>
<Text className="text-white/80 text-base">Cancel</Text>
</Pressable>
</View>
</SafeAreaView>
<SafeAreaView
edges={["bottom"]}
className="absolute bottom-0 left-0 right-0"
>
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
<Pressable
onPress={onRetake}
disabled={submitting}
className="rounded-full bg-white/15 px-5 py-3"
accessibilityLabel="Retake"
>
<Text className="text-white text-base font-medium">Retake</Text>
</Pressable>
<Pressable
onPress={handleSend}
disabled={submitting}
className={cn(
"rounded-full px-7 py-3",
submitting ? "bg-white/40" : "bg-white",
)}
accessibilityLabel="Send"
>
<Text className="text-black text-base font-semibold">
{submitting ? "Sending..." : "Send"}
</Text>
</Pressable>
</View>
</SafeAreaView>
</View>
</Modal>
);
}
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveStyle(length: number) {
if (length === 0)
return { className: "text-3xl font-semibold leading-snug" };
if (length < 30)
return { className: "text-5xl font-semibold leading-tight" };
if (length < 70)
return { className: "text-3xl font-semibold leading-snug" };
return { className: "text-2xl font-normal leading-snug" };
}
interface TextComposeModalProps {
open: boolean;
onClose: () => void;
/**
* Submit handler — must throw if the upload fails so the modal can re-show
* the editor and the user doesn't lose their text.
*/
onSubmit: (content: string) => Promise<void>;
}
/**
* Immersive full-screen text editor. Mirrors desktop's `text-editor.tsx`:
* dynamic font ramp at 30/70/130 chars, no markdown preview, no gradient.
* Long messages scroll inside the multiline TextInput. Pauses upstream
* playback (the host wraps render in a useSuspendPlayback while open).
*/
export function TextComposeModal({
open,
onClose,
onSubmit,
}: TextComposeModalProps) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<TextInput>(null);
// Reset whenever the modal opens fresh.
useEffect(() => {
if (open) {
setContent("");
setSubmitting(false);
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
// the modal animation is mid-flight.
const t = setTimeout(() => inputRef.current?.focus(), 60);
return () => clearTimeout(t);
}
}, [open]);
const trimmed = content.trim();
const canSend = trimmed.length > 0 && !submitting;
const handleSend = async () => {
if (!canSend) return;
setSubmitting(true);
try {
await onSubmit(trimmed);
onClose();
} catch (err) {
toast.error(toUserMessage(err));
setSubmitting(false);
}
};
const style = getImmersiveStyle(trimmed.length);
const isImmersive = trimmed.length < IMMERSIVE_CHAR_LIMIT;
return (
<Modal
visible={open}
animationType="fade"
transparent={false}
statusBarTranslucent
onRequestClose={onClose}
>
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-row items-center justify-between px-4 py-3">
<Pressable
onPress={onClose}
accessibilityLabel="Cancel"
hitSlop={12}
>
<Text className="text-white/70 text-base">Cancel</Text>
</Pressable>
<Pressable
onPress={handleSend}
disabled={!canSend}
hitSlop={12}
accessibilityLabel="Send"
>
<Text
className={cn(
"text-base font-semibold",
canSend ? "text-white" : "text-white/30",
)}
>
{submitting ? "Sending..." : "Send"}
</Text>
</Pressable>
</View>
<View className="flex-1 justify-center px-6 pb-6">
<TextInput
ref={inputRef}
value={content}
onChangeText={setContent}
placeholder="Type a message"
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
autoFocus
autoCorrect
autoCapitalize="sentences"
editable={!submitting}
scrollEnabled={!isImmersive}
textAlignVertical={isImmersive ? "center" : "top"}
style={{
color: "white",
textAlign: isImmersive ? "center" : "left",
maxHeight: isImmersive ? undefined : 540,
}}
className={cn("text-white", style.className)}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
);
}
@@ -0,0 +1,351 @@
import { useEffect, useRef, useState } from "react";
import { Dimensions, StyleSheet, Text, View } from "react-native";
import { CameraView, type CameraView as CameraViewRef } from "expo-camera";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useEvent } from "@/hooks/use-event";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
const FAB_BOTTOM_INSET = 96;
// iOS race: `onCameraReady` fires before the underlying AVCaptureSession is
// fully configured to write a movie file, so the first `recordAsync` call
// can throw "Camera is not ready yet" even though we waited for the
// callback. Empirically a small settle delay + one retry covers it.
const READY_SETTLE_MS = 250;
const RETRY_DELAY_MS = 400;
interface VideoRecordingOverlayProps {
/** 0..1 — drives the cancel-pill animation as the user drags up. */
cancelProgress: { value: number };
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
/** Fires once recordAsync has actually started writing the file. */
onRecordingStarted: () => void;
/**
* Set by the parent on release: "commit" stops + ships, "cancel" stops +
* discards. Mirrors the audio overlay's contract so both lifecycles look
* identical to the dock.
*/
pendingAction: "commit" | "cancel" | null;
}
/**
* Full-screen camera preview that auto-starts a recording on mount and stops
* when the parent supplies `pendingAction`. The overlay does NOT own the
* touch gesture — the ComposeDock holds the Pan that started this recording.
* We just visualize cancel progress.
*/
export function VideoRecordingOverlay({
cancelProgress,
onComplete,
onCancel,
onRecordingStarted,
pendingAction,
}: VideoRecordingOverlayProps) {
const cameraRef = useRef<CameraViewRef>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const [cameraReady, setCameraReady] = useState(false);
const [warming, setWarming] = useState(true);
// Lifecycle refs — refs over state so async closures see the latest value
// without re-running effects.
const startedAtRef = useRef<number | null>(null);
const recordingActiveRef = useRef(false);
const completionRef = useRef<"pending" | "completed" | "cancelled">(
"pending",
);
const onCompleteStable = useEvent(onComplete);
const onCancelStable = useEvent(onCancel);
const onStartedStable = useEvent(onRecordingStarted);
const handleCameraReady = useEvent(() => {
setCameraReady(true);
});
// ---- Recording lifecycle ----
// The flow is intentionally a single effect that runs once `cameraReady`
// flips: settle, attempt, on race-error retry once. Any path back to
// failure routes through onCancelStable so the dock returns to idle.
useEffect(() => {
if (!cameraReady) return;
let cancelled = false;
const tryStart = async () => {
// Settle delay — the AVCaptureSession needs a beat after onCameraReady.
await wait(READY_SETTLE_MS);
if (cancelled || completionRef.current !== "pending") return;
const cam = cameraRef.current;
if (!cam) {
onCancelStable();
return;
}
const attempt = async (): Promise<{ uri: string } | null> => {
try {
// recordAsync resolves only when stopRecording is called (or
// maxDuration hits). We start a 100ms watchdog so the parent
// doesn't see "recording" until we know recordAsync didn't
// synchronously reject.
startedAtRef.current = Date.now();
recordingActiveRef.current = true;
const recordPromise = cam.recordAsync({
maxDuration: MAX_DURATION_S,
});
const startedTimer = setTimeout(() => {
if (completionRef.current === "pending") {
setWarming(false);
onStartedStable();
}
}, 100);
const result = await recordPromise;
clearTimeout(startedTimer);
return result ?? null;
} catch (err) {
recordingActiveRef.current = false;
throw err;
}
};
try {
const result = await attempt();
if (cancelled) return;
finalizeWithResult(result);
} catch (err) {
const msg = (err as Error)?.message ?? "";
const isReadyRace =
msg.includes("not ready") ||
msg.includes("Camera is not ready");
if (!isReadyRace) {
logError(err, { scope: "compose.video.recordAsync" });
if (!cancelled) onCancelStable();
return;
}
// One retry. The native session usually settles within ~400ms after
// the first throw; longer than that and it's not the ready-race.
await wait(RETRY_DELAY_MS);
if (cancelled || completionRef.current !== "pending") return;
try {
const result = await attempt();
if (cancelled) return;
finalizeWithResult(result);
} catch (err2) {
logError(err2, {
scope: "compose.video.recordAsync.retry",
});
if (!cancelled) onCancelStable();
}
}
};
const finalizeWithResult = (result: { uri: string } | null) => {
recordingActiveRef.current = false;
if (completionRef.current === "cancelled") return;
completionRef.current = "completed";
const durationMs =
startedAtRef.current !== null
? Date.now() - startedAtRef.current
: 0;
if (result?.uri) {
onCompleteStable({ uri: result.uri, durationMs });
} else {
onCancelStable();
}
};
void tryStart();
return () => {
cancelled = true;
};
}, [cameraReady, onCancelStable, onCompleteStable, onStartedStable]);
// ---- Parent-issued action: commit / cancel ----
useEffect(() => {
if (!pendingAction) return;
if (pendingAction === "cancel") {
completionRef.current = "cancelled";
}
// If recording hasn't actually started yet (still warming or retrying),
// there's nothing to stop on the camera; route directly to cancel so
// the parent isn't left waiting for a recordAsync that may take
// another retry cycle to ever start.
if (!recordingActiveRef.current) {
onCancelStable();
return;
}
cameraRef.current?.stopRecording();
}, [pendingAction, onCancelStable]);
// Safety: if the overlay unmounts mid-recording, cancel the file.
useEffect(() => {
return () => {
if (completionRef.current === "pending") {
completionRef.current = "cancelled";
if (recordingActiveRef.current) {
cameraRef.current?.stopRecording();
}
}
};
}, []);
// Elapsed counter: 100ms tick.
useEffect(() => {
const interval = setInterval(() => {
if (startedAtRef.current === null) return;
setElapsedMs(Date.now() - startedAtRef.current);
}, 100);
return () => clearInterval(interval);
}, []);
const elapsedSec = Math.floor(elapsedMs / 1000);
const elapsedRatio = Math.min(elapsedMs / (MAX_DURATION_S * 1000), 1);
return (
<View style={StyleSheet.absoluteFill} className="bg-black">
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFill}
facing="front"
mode="video"
videoQuality="1080p"
onCameraReady={handleCameraReady}
mute={false}
/>
<RecChrome
warming={warming}
elapsedSec={elapsedSec}
elapsedRatio={elapsedRatio}
cancelProgress={cancelProgress}
/>
</View>
);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function RecChrome({
warming,
elapsedSec,
elapsedRatio,
cancelProgress,
}: {
warming: boolean;
elapsedSec: number;
elapsedRatio: number;
cancelProgress: { value: number };
}) {
const screenHeight = Dimensions.get("window").height;
const fabCenterY = screenHeight - FAB_BOTTOM_INSET - 36;
const cancelPillStyle = useAnimatedStyle(() => {
const progress = Math.max(0, Math.min(cancelProgress.value, 1));
return {
opacity: progress,
transform: [{ translateY: -40 * progress }],
};
});
return (
<>
{/* Top REC / warming indicator. */}
<View
pointerEvents="none"
className="absolute top-0 left-0 right-0 items-center pt-16"
>
{warming ? (
<View className="bg-black/70 flex-row items-center gap-2 rounded-full px-3 py-1.5">
<PulsingDot color="white" />
<Text className="text-white text-xs font-medium">
Getting ready
</Text>
</View>
) : (
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
<PulsingDot color="white" />
<Text className="text-white text-xs font-semibold tracking-wide">
REC · {elapsedSec.toString().padStart(2, "0")}s
</Text>
</View>
)}
</View>
{/* Cancel pill — slides in as user drags up. */}
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
left: 0,
right: 0,
top: fabCenterY - 96,
alignItems: "center",
},
cancelPillStyle,
]}
>
<View className="bg-black/70 px-4 py-2 rounded-full border border-white/20">
<Text className="text-white text-sm font-medium">
Slide up to cancel
</Text>
</View>
</Animated.View>
{/* Tiny duration counter near the FAB. */}
{!warming ? (
<View
pointerEvents="none"
className="absolute bottom-32 left-0 right-0 items-center"
>
<Text className="text-white/60 text-xs">
{Math.round(elapsedRatio * 60)}s / {MAX_DURATION_S}s
</Text>
</View>
) : null}
</>
);
}
function PulsingDot({ color = "white" }: { color?: string }) {
const opacity = useSharedValue(1);
useEffect(() => {
const loop = () => {
opacity.value = withTiming(
0.3,
{ duration: 600, easing: Easing.inOut(Easing.quad) },
() => {
opacity.value = withTiming(
1,
{ duration: 600, easing: Easing.inOut(Easing.quad) },
() => loop(),
);
},
);
};
loop();
}, [opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View
className="h-2 w-2 rounded-full"
style={[{ backgroundColor: color }, style]}
/>
);
}
@@ -1,7 +1,12 @@
import { useEffect } from "react";
import { Text, View } from "react-native";
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { Mic, Video as VideoIcon } from "lucide-react-native";
import { useEventListener } from "expo";
import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video";
import type { Particle } from "@/api/types";
import { apiClient } from "@/api/client";
import { logError } from "@/lib/errors";
import { useEvent } from "@/hooks/use-event";
type MediaParticle = Extract<Particle, { type: "media" }>;
@@ -12,43 +17,213 @@ interface MediaParticleViewProps {
onProgress: (ratio: number) => void;
}
// Step 4 placeholder. Step 5 replaces this with `expo-video` playback for MP4
// (camera + audio-only mp4) and "View on desktop" for legacy WebM. For now the
// view advances on a 5s timer so the rest of the playback shell is exercisable
// against existing media particles (which would currently be desktop WebM).
const PLACEHOLDER_DURATION_S = 5;
const TICK_MS = 100;
const TICK_MS = 150;
/**
* Plays MP4 / MOV / m4a content via expo-video. Desktop currently records
* WebM, which AVPlayer can't decode; the particle processor worker produces
* an iOS-playable MP4/m4a variant and writes `transcoded_object_id` /
* `transcoded_mime_type` to the particle. While that work is in flight, we
* show a "Processing for mobile…" placeholder and let the Firestore listener
* swap us into the playable state once the worker finishes.
*
* The signed download URL is fetched lazily via apiClient.getParticleDownloadUrl
* (Orion-issued, time-limited). We show a spinner while that resolves, then
* mount the player and report progress via a 150ms tick reading the player's
* currentTime — same model as desktop's MediaParticleView.
*/
export function MediaParticleView({
particle,
paused,
onEnded,
onProgress,
}: MediaParticleViewProps) {
const isAudio = particle.properties.mime_type.startsWith("audio/");
const isMp4 =
particle.properties.mime_type === "video/mp4" ||
particle.properties.mime_type === "audio/mp4";
const activeObjectId =
particle.properties.transcoded_object_id ?? particle.properties.object_id;
const activeMime =
particle.properties.transcoded_mime_type ?? particle.properties.mime_type;
const isAudio = activeMime.startsWith("audio/");
const isPlayable = isPlayableMime(activeMime);
// Reset progress as the active particle changes — independent of playback
// state — so the segmented bar drops back to 0 immediately.
useEffect(() => {
onProgress(0);
}, [particle.id, onProgress]);
if (!isPlayable) {
return <ProcessingForMobilePlaceholder isAudio={isAudio} />;
}
return (
<PlayableMediaView
particle={particle}
activeObjectId={activeObjectId}
isAudio={isAudio}
paused={paused}
onEnded={onEnded}
onProgress={onProgress}
/>
);
}
function PlayableMediaView({
particle,
activeObjectId,
isAudio,
paused,
onEnded,
onProgress,
}: {
particle: MediaParticle;
activeObjectId: string;
isAudio: boolean;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}) {
const [sourceUri, setSourceUri] = useState<string | null>(null);
const [resolveError, setResolveError] = useState<Error | null>(null);
// Fetch the signed download URL once per active object. Orion URLs are
// time-limited — we treat the URL as one-shot for this view's lifetime.
// Re-runs when the worker writes `transcoded_object_id` and the parent
// resolves a new active object id.
useEffect(() => {
if (paused) return;
let elapsed = 0;
let cancelled = false;
setSourceUri(null);
setResolveError(null);
apiClient
.getParticleDownloadUrl(activeObjectId)
.then((url) => {
if (!cancelled) setSourceUri(url);
})
.catch((err) => {
logError(err, { scope: "media.download-url" });
if (!cancelled) setResolveError(err as Error);
});
return () => {
cancelled = true;
};
}, [activeObjectId, particle.id]);
const player = useVideoPlayer(sourceUri ?? "", (p) => {
p.loop = false;
p.muted = false;
p.timeUpdateEventInterval = 0.15;
});
// Drive play/pause from the suspender store. The player itself is forgiving
// about extra play/pause calls so we don't gate this.
useEffect(() => {
if (!sourceUri) return;
if (paused) {
player.pause();
} else {
player.play();
}
}, [paused, sourceUri, player]);
// End-of-clip → advance. We listen to status flips rather than computing
// duration ratios because video duration may be 0 for the first frame or two.
useEventListener(player, "statusChange", ({ status }) => {
if (status === ("idle" satisfies VideoPlayerStatus)) {
// ignored — happens during source swap
}
});
const onEndedStable = useEvent(onEnded);
const onProgressStable = useEvent(onProgress);
// Progress tick: report currentTime / duration each TICK_MS. Bail when the
// player isn't ready yet (duration = 0).
useEffect(() => {
if (!sourceUri || paused) return;
const interval = setInterval(() => {
elapsed += TICK_MS / 1000;
const ratio = Math.min(elapsed / PLACEHOLDER_DURATION_S, 1);
onProgress(ratio);
if (ratio >= 1) {
const duration = player.duration;
if (!duration || duration <= 0) return;
const ratio = Math.min(player.currentTime / duration, 1);
onProgressStable(ratio);
if (ratio >= 0.999) {
clearInterval(interval);
onEnded();
onEndedStable();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, onEnded, onProgress, particle.id]);
}, [sourceUri, paused, player, onEndedStable, onProgressStable]);
if (resolveError) {
return (
<View className="flex-1 items-center justify-center px-8">
<Text className="text-white/80 text-base text-center">
Couldn't load this {isAudio ? "voice message" : "video"}.
</Text>
<Text className="text-white/50 text-sm text-center mt-2">
Tap forward to continue.
</Text>
</View>
);
}
if (!sourceUri) {
return (
<View className="flex-1 items-center justify-center bg-black">
<ActivityIndicator color="white" />
</View>
);
}
// Audio-only: hide the (blank) video surface and show a static face. The
// VideoView still renders 0×0 so the audio track keeps playing.
if (isAudio) {
return (
<View className="flex-1 items-center justify-center px-8">
<View
className="absolute"
style={{ width: 0, height: 0, opacity: 0 }}
pointerEvents="none"
>
<VideoView
style={{ width: 1, height: 1 }}
player={player}
nativeControls={false}
allowsFullscreen={false}
allowsPictureInPicture={false}
/>
</View>
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
<Mic color="white" size={36} strokeWidth={1.5} />
</View>
<Text className="text-white mt-6 text-lg font-medium">
Voice message
</Text>
</View>
);
}
// Video: full-bleed. The PRD calls for fit-fill (cover) so portrait mobile
// captures fill the screen — desktop pillarboxes its 4:3 captures to feel
// similarly framed.
return (
<View className="flex-1 bg-black">
<VideoView
style={{ flex: 1 }}
player={player}
nativeControls={false}
contentFit="cover"
allowsFullscreen={false}
allowsPictureInPicture={false}
/>
</View>
);
}
// Shown while the particle processor worker is producing the iOS-playable
// MP4/m4a variant. The Firestore listener will re-render this view once
// `transcoded_object_id` lands on the particle, which swaps us into
// PlayableMediaView. We deliberately do not auto-advance — the user is here
// to consume this content; if the worker is slow they can tap forward.
function ProcessingForMobilePlaceholder({ isAudio }: { isAudio: boolean }) {
return (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
@@ -61,11 +236,28 @@ export function MediaParticleView({
<Text className="text-white mt-6 text-lg font-medium">
{isAudio ? "Voice message" : "Video message"}
</Text>
<Text className="text-white/60 mt-2 text-sm text-center">
{isMp4
? "Playback wires up in step 5."
: "Recorded on desktop — view there until codecs converge."}
<View className="flex-row items-center mt-3">
<ActivityIndicator color="white" />
<Text className="text-white/60 ml-3 text-sm">
Processing for mobile
</Text>
</View>
<Text className="text-white/40 mt-2 text-xs text-center">
This message will play in a moment.
</Text>
</View>
);
}
function isPlayableMime(mime: string): boolean {
// expo-video uses AVPlayer on iOS — reliable for h264 in mp4 / mov / m4a.
// WebM/VP9 (the legacy desktop format) is not decodable.
return (
mime === "video/mp4" ||
mime === "video/quicktime" ||
mime === "audio/mp4" ||
mime === "audio/aac" ||
mime === "audio/x-m4a" ||
mime === "audio/mpeg"
);
}
@@ -0,0 +1,362 @@
import { useEffect, useMemo, useState } from "react";
import {
Dimensions,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Send, X } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import {
Gesture,
GestureDetector,
} from "react-native-gesture-handler";
import Animated, {
Easing,
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
import type { Human } from "@/api/types";
import { resolveHumanDisplay } from "@/lib/humans";
import { cn } from "@/lib/utils";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
const TEXT_REACTION_MAX = 40;
const SCREEN_HEIGHT = Dimensions.get("window").height;
const ANIMATION_MS = 240;
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
interface ReactionSheetProps {
open: boolean;
onClose: () => void;
reactions: Reactions;
currentHumanId: string;
humans: Human[] | undefined;
/**
* Toggle a reaction (emoji or text). Adds if the current human hasn't
* reacted, removes if they have. Mirrors desktop's `onToggle` exactly.
*/
onToggle: (key: string) => void;
}
/**
* Slide-up reaction sheet — the mobile replacement for desktop's right-edge
* reaction stack. Tap an emoji to toggle, or send a custom text reaction
* (40-char cap). Existing reactions appear as toggleable pills at the top.
*
* Playback is suspended via `useSuspendPlayback` while the sheet is open so
* the active particle doesn't auto-advance under the user. Drag the sheet
* down past 30% of its travel to dismiss; everything else springs back.
*/
export function ReactionSheet({
open,
onClose,
reactions,
currentHumanId,
humans,
onToggle,
}: ReactionSheetProps) {
// Suspend playback whenever the sheet is mounted-and-open. The Modal
// controls visibility so we tie the suspender to `open` directly.
useSuspendPlayback(open, "reactions-sheet");
// We mount the modal slightly delayed from `open` so the slide-up animation
// has its starting position rendered. Using local `mounted` state lets us
// play the close animation before unmounting.
const [mounted, setMounted] = useState(false);
const translateY = useSharedValue(SCREEN_HEIGHT);
useEffect(() => {
if (open) {
setMounted(true);
// Schedule animation after the modal mounts
requestAnimationFrame(() => {
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
});
} else if (mounted) {
translateY.value = withTiming(
SCREEN_HEIGHT,
{ duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) },
(finished) => {
if (finished) runOnJS(setMounted)(false);
},
);
}
// intentional: only react to `open`. Closing animation reads from `mounted`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const dismiss = () => {
onClose();
};
const sheetPan = Gesture.Pan()
.activeOffsetY(10)
.failOffsetX([-25, 25])
.onUpdate((e) => {
"worklet";
translateY.value = Math.max(0, e.translationY);
})
.onEnd((e) => {
"worklet";
if (e.translationY > 120 || e.velocityY > 800) {
runOnJS(dismiss)();
} else {
translateY.value = withSpring(0, {
damping: 24,
stiffness: 260,
mass: 0.7,
});
}
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => {
const opacity = interpolate(
translateY.value,
[0, SCREEN_HEIGHT * 0.7],
[0.55, 0],
Extrapolation.CLAMP,
);
return { opacity };
});
// --- Existing reaction pills ---
const activeEmojis = REACTION_EMOJIS.filter(
(e) => reactions?.[e] && (reactions[e]?.length ?? 0) > 0,
);
const activeTextKeys = useMemo(
() =>
Object.keys(reactions ?? {}).filter(
(k) =>
!EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
),
[reactions],
);
// --- Text reaction input ---
const [text, setText] = useState("");
useEffect(() => {
if (open) setText("");
}, [open]);
const submitText = () => {
const trimmed = text.trim();
if (!trimmed) return;
void Haptics.selectionAsync();
onToggle(trimmed.slice(0, TEXT_REACTION_MAX));
setText("");
onClose();
};
const handleEmoji = (emoji: string) => {
void Haptics.selectionAsync();
onToggle(emoji);
onClose();
};
if (!mounted) return null;
return (
<Modal
visible={mounted}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={dismiss}
>
<View style={{ flex: 1 }}>
<Animated.View
pointerEvents={open ? "auto" : "none"}
style={[
{ position: "absolute", inset: 0, backgroundColor: "black" },
backdropStyle,
]}
>
<Pressable style={{ flex: 1 }} onPress={dismiss} />
</Animated.View>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1, justifyContent: "flex-end" }}
pointerEvents="box-none"
>
<GestureDetector gesture={sheetPan}>
<Animated.View
style={[
{
backgroundColor: "#1c1c1c",
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
overflow: "hidden",
},
sheetStyle,
]}
>
<SafeAreaView edges={["bottom"]}>
<View className="px-5 pt-3 pb-2 items-center">
{/* Drag handle — affords downward dismissal at a glance. */}
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
<View className="flex-row items-center justify-between w-full">
<Text className="text-white text-base font-semibold">
React
</Text>
<Pressable
onPress={dismiss}
hitSlop={12}
accessibilityLabel="Close reactions"
>
<X color="rgba(255,255,255,0.6)" size={20} />
</Pressable>
</View>
</View>
{/* Existing reactions row — tap a pill to toggle yours. */}
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
{activeEmojis.map((emoji) => {
const reactors = reactions![emoji];
const isMine = reactors.includes(currentHumanId);
return (
<Pressable
key={emoji}
onPress={() => handleEmoji(emoji)}
className={cn(
"flex-row items-center gap-1.5 rounded-full px-3 py-1.5",
isMine
? "bg-white/25 border border-white/40"
: "bg-white/10",
)}
>
<Text className="text-base">{emoji}</Text>
<Text className="text-white/85 text-xs font-semibold">
{reactors.length}
</Text>
</Pressable>
);
})}
{activeTextKeys.map((key) => {
const reactors = reactions![key];
const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(
reactors[0],
humans,
);
return (
<Pressable
key={key}
onPress={() => handleEmoji(key)}
className={cn(
"flex-row items-center gap-1.5 rounded-full px-2.5 py-1.5 max-w-[220px]",
isMine
? "bg-white/25 border border-white/40"
: "bg-white/10",
)}
>
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Text
className="text-white/90 text-xs"
numberOfLines={1}
>
{key}
</Text>
{reactors.length > 1 ? (
<Text className="text-white/60 text-xs font-medium">
{reactors.length}
</Text>
) : null}
</Pressable>
);
})}
</View>
) : null}
{/* Quick-pick emoji palette — six big tappable buttons. */}
<View className="px-3 pt-2 pb-4 flex-row justify-around">
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
const isMine =
reactions?.[emoji]?.includes(currentHumanId) ?? false;
return (
<Pressable
key={emoji}
onPress={() => handleEmoji(emoji)}
accessibilityLabel={`React with ${emoji}`}
className={cn(
"h-14 w-14 items-center justify-center rounded-full",
isMine ? "bg-white/25" : "bg-white/10",
)}
>
<Text style={{ fontSize: 28 }}>{emoji}</Text>
</Pressable>
);
})}
</View>
{/* Text reaction input — 40-char cap matches desktop. */}
<View className="px-4 pb-4 flex-row items-center gap-2">
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
<TextInput
value={text}
onChangeText={(v) => setText(v.slice(0, TEXT_REACTION_MAX))}
placeholder="Send a quick reply..."
placeholderTextColor="rgba(255,255,255,0.4)"
maxLength={TEXT_REACTION_MAX}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={submitText}
returnKeyType="send"
className="text-white text-base"
/>
</View>
<Pressable
onPress={submitText}
disabled={text.trim().length === 0}
accessibilityLabel="Send text reaction"
className={cn(
"h-11 w-11 items-center justify-center rounded-full",
text.trim().length === 0
? "bg-white/10"
: "bg-white",
)}
>
<Send
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
size={18}
strokeWidth={2}
/>
</Pressable>
</View>
</SafeAreaView>
</Animated.View>
</GestureDetector>
</KeyboardAvoidingView>
</View>
</Modal>
);
}
+127 -10
View File
@@ -17,7 +17,13 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { isParticleDeleted, type Particle } from "@/api/types";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import {
parseParticlePath,
particlePath,
toFirestoreDocPath,
type ParticlePath,
} from "@/lib/particle-path";
import { toggleParticleReaction } from "@/lib/firestore-particles";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
@@ -25,8 +31,17 @@ import {
selectIsPaused,
usePlaybackPauseStore,
} from "@/stores/playback-pause-store";
import { useAuthStore } from "@/stores/auth-store";
import { ComposeDock } from "@/features/compose/ComposeDock";
import { ComposingIndicator } from "@/components/ComposingIndicator";
import { PlaybackPageIndicator } from "./PlaybackPageIndicator";
import { ReactionSheet } from "./ReactionSheet";
import { StreamMetadataHeader } from "./StreamMetadataHeader";
import { StreamSafeAreaProvider } from "./stream-safe-area";
import {
StreamPresenceProvider,
useStreamComposing,
} from "./stream-presence-context";
import { TextParticleView } from "./TextParticleView";
import { MediaParticleView } from "./MediaParticleView";
import { DeletedParticleView } from "./DeletedParticleView";
@@ -41,6 +56,9 @@ const PREV_ZONE_RATIO = 0.28;
// flick downward fast enough.
const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25;
const DISMISS_VELOCITY = 900;
// Swipe-up reactions commit thresholds — flick up ~80px or with enough velocity.
const REACTIONS_DISTANCE = 80;
const REACTIONS_VELOCITY = 600;
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
@@ -48,22 +66,67 @@ interface StreamViewProps {
onExit: () => void;
}
export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
export function StreamView(props: StreamViewProps) {
const { networkId } = parseParticlePath(props.path);
// The presence provider wraps the inner view so any descendant can broadcast
// composing state without re-deriving the channel id.
return (
<StreamPresenceProvider
networkId={networkId}
streamId={props.streamParticle.id}
>
<StreamViewInner {...props} />
</StreamPresenceProvider>
);
}
function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
const network = useNetwork(networkId);
const insets = useSafeAreaInsets();
const { composingUsers } = useStreamComposing();
const { children, currentParticle, currentIndex, status, next, prev } =
useStreamPlayback(streamParticle, path);
const paused = usePlaybackPauseStore(selectIsPaused);
const [progress, setProgress] = useState(0);
const userId = useAuthStore((s) => s.user?.id) ?? "";
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
// setter inside a runOnJS callback dispatched from the worklet thread.
const [holdActive, setHoldActive] = useState(false);
useSuspendPlayback(holdActive, "touch-hold");
// Reaction sheet — opens via swipe-up on the canvas.
const [reactionsOpen, setReactionsOpen] = useState(false);
const reactionsOnCurrent =
currentParticle && !isParticleDeleted(currentParticle)
? currentParticle.type === "media" || currentParticle.type === "text"
? currentParticle.reactions
: undefined
: undefined;
const handleToggleReaction = useCallback(
(key: string) => {
if (!userId || !currentParticle) return;
if (isParticleDeleted(currentParticle)) return;
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id, currentParticle.id]),
);
void toggleParticleReaction(
docPath,
key,
userId,
reactionsOnCurrent,
);
},
[userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent],
);
const openReactions = useCallback(() => setReactionsOpen(true), []);
// Reset progress whenever the active particle changes.
useEffect(() => {
setProgress(0);
@@ -93,7 +156,7 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
onExit();
}, [onExit]);
const pan = Gesture.Pan()
const panDown = Gesture.Pan()
.activeOffsetY(15)
.failOffsetX([-30, 30])
.failOffsetY(-20)
@@ -118,6 +181,22 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
}
});
// Swipe-up opens the reaction sheet. Mirror the down pan's discipline —
// fail on horizontal motion so it doesn't fight the tap-zones.
const panUp = Gesture.Pan()
.activeOffsetY(-15)
.failOffsetX([-30, 30])
.failOffsetY(20)
.onEnd((e) => {
"worklet";
if (
e.translationY < -REACTIONS_DISTANCE ||
e.velocityY < -REACTIONS_VELOCITY
) {
runOnJS(openReactions)();
}
});
// --- Tap (advance / regress) ---
const tap = Gesture.Tap()
.maxDuration(180)
@@ -146,9 +225,13 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
runOnJS(setHoldActive)(false);
});
// Pan races the tap+longPress combo: vertical drag activates pan and
// cancels the others; otherwise tap and long-press run simultaneously.
const composed = Gesture.Race(pan, Gesture.Simultaneous(tap, longPress));
// Pan-down (dismiss), pan-up (reactions), and tap+longPress race against
// each other. The first to clear its activeOffsetY wins; the others fail.
const composed = Gesture.Race(
panDown,
panUp,
Gesture.Simultaneous(tap, longPress),
);
const containerStyle = useAnimatedStyle(() => {
const opacity = interpolate(
@@ -182,6 +265,12 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
// --- End-of-stream countdown ---
const exitRemainingMs = useExitCountdown(status, paused, exit);
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
// metadata row (~38) + breathing room (12). Bottom = safe-area + room for
// pause / countdown pills + the compose dock that lands in this same step.
const chromeTop = insets.top + 65;
const chromeBottom = insets.bottom + 96;
// --- Render the active particle ---
const renderParticle = (particle: Particle) => {
if (isParticleDeleted(particle)) {
@@ -250,10 +339,14 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
<GestureDetector gesture={composed}>
<View className="flex-1">
{/* Particle canvas — fills the whole screen, gesture-aware. */}
<View className="flex-1">
{currentParticle ? renderParticle(currentParticle) : null}
</View>
{/* Particle canvas — fills the whole screen, gesture-aware.
StreamSafeAreaProvider tells particle views how much space the
chrome occupies so scrollable content doesn't slip under. */}
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
<View className="flex-1">
{currentParticle ? renderParticle(currentParticle) : null}
</View>
</StreamSafeAreaProvider>
{/* Top chrome: segmented bar + metadata. Painted over the canvas
so the canvas can be edge-to-edge but content gets a safe-area
@@ -283,6 +376,14 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
particle={currentParticle}
network={network ?? null}
/>
{composingUsers.length > 0 ? (
<View className="mt-2">
<ComposingIndicator
users={composingUsers}
networkHumans={network?.humans}
/>
</View>
) : null}
</View>
</View>
@@ -314,6 +415,22 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
iOS's status-bar tap doesn't fight our gestures. */}
<SafeAreaView edges={["top"]} pointerEvents="none" />
{/* Compose dock + recording overlays. Sits above the GestureDetector
so its hold-FAB pan gesture isn't competed-with by the StreamView
tap zones. */}
<ComposeDock networkId={networkId} targetPath={path} />
{/* Reaction sheet — slides up over everything, suspends playback
internally while open. */}
<ReactionSheet
open={reactionsOpen}
onClose={() => setReactionsOpen(false)}
reactions={reactionsOnCurrent}
currentHumanId={userId}
humans={network?.humans}
onToggle={handleToggleReaction}
/>
</Animated.View>
</Animated.View>
);
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { ScrollView, Text, View } from "react-native";
import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
import { useStreamSafeArea } from "./stream-safe-area";
type TextParticle = Extract<Particle, { type: "text" }>;
@@ -44,6 +45,7 @@ export function TextParticleView({
const content = particle.properties.content;
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
const safe = useStreamSafeArea();
// Reset when the particle changes.
useEffect(() => {
@@ -71,7 +73,13 @@ export function TextParticleView({
if (content.length < IMMERSIVE_CHAR_LIMIT) {
const style = getImmersiveStyle(content.length);
return (
<View className="flex-1 items-center justify-center px-8">
<View
className="flex-1 items-center justify-center px-8"
style={{
paddingTop: safe.top + 16,
paddingBottom: safe.bottom + 16,
}}
>
<Text
className={cn("text-white text-center max-w-xl", style.className)}
>
@@ -83,9 +91,16 @@ export function TextParticleView({
// Long text: scrollable card so the reader can pace themselves; the
// duration timer keeps ticking either way, which is intentional —
// long messages should still auto-advance at the 15s cap.
// long messages should still auto-advance at the 15s cap. Padding is
// pulled from the StreamSafeArea so the card never slips under chrome.
return (
<View className="flex-1 items-center justify-center px-6 py-20">
<View
className="flex-1 items-center justify-center px-6"
style={{
paddingTop: safe.top + 16,
paddingBottom: safe.bottom + 16,
}}
>
<ScrollView
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5"
@@ -0,0 +1,203 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useChannel } from "@/hooks/use-channel";
import { useAuthStore } from "@/stores/auth-store";
export type ComposingMode = "recording" | "typing" | "screen";
export interface ComposingUser {
humanId: string;
mode: ComposingMode;
lastSeen: number;
}
interface StreamPresenceContextValue {
onlineHumanIds: Set<string>;
composingUsers: ComposingUser[];
startComposing: (mode: ComposingMode) => void;
stopComposing: () => void;
}
const COMPOSING_TIMEOUT_MS = 10_000;
const COMPOSING_HEARTBEAT_MS = 5_000;
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
null,
);
interface StreamPresenceProviderProps {
networkId: string;
streamId: string;
children: ReactNode;
}
export function StreamPresenceProvider({
networkId,
streamId,
children,
}: StreamPresenceProviderProps) {
const channelId = `stream:${networkId}:${streamId}`;
const { presence, messages, sendMessage } = useChannel(channelId);
const currentUserId = useAuthStore((s) => s.user?.id);
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
// --- Composing state ---
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
const composingMapRef = useRef(new Map<string, ComposingUser>());
const processedCountRef = useRef(0);
// Process new messages incrementally — slicing the messages array means
// we don't re-scan the whole history every render.
useEffect(() => {
if (messages.length <= processedCountRef.current) return;
const newMessages = messages.slice(processedCountRef.current);
processedCountRef.current = messages.length;
let changed = false;
const map = composingMapRef.current;
for (const msg of newMessages) {
const payload = msg.payload as
| { type: string; mode?: string }
| undefined;
if (!payload?.type) continue;
if (msg.humanId === currentUserId) continue;
if (payload.type === "composing_start" && payload.mode) {
map.set(msg.humanId, {
humanId: msg.humanId,
mode: payload.mode as ComposingMode,
lastSeen: Date.now(),
});
changed = true;
} else if (payload.type === "composing_stop") {
if (map.delete(msg.humanId)) changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [messages, currentUserId]);
// Drop composing entries when a user leaves the channel — covers the
// "they backgrounded the app without sending stop" case.
useEffect(() => {
const map = composingMapRef.current;
const onlineSet = new Set(presence);
let changed = false;
for (const humanId of map.keys()) {
if (!onlineSet.has(humanId)) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, [presence]);
// Sweep stale composing entries (last heartbeat > 10s ago).
useEffect(() => {
const interval = setInterval(() => {
const map = composingMapRef.current;
const now = Date.now();
let changed = false;
for (const [humanId, entry] of map) {
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
map.delete(humanId);
changed = true;
}
}
if (changed) {
setComposingUsers(Array.from(map.values()));
}
}, COMPOSING_CLEANUP_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
// --- Composing broadcast ---
const heartbeatRef = useRef<ReturnType<typeof setInterval> | undefined>(
undefined,
);
const startComposing = useCallback(
(mode: ComposingMode) => {
sendMessage({ type: "composing_start", mode });
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
heartbeatRef.current = setInterval(() => {
sendMessage({ type: "composing_start", mode });
}, COMPOSING_HEARTBEAT_MS);
},
[sendMessage],
);
const stopComposing = useCallback(() => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
heartbeatRef.current = undefined;
sendMessage({ type: "composing_stop" });
}, [sendMessage]);
useEffect(() => {
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
};
}, []);
const value = useMemo<StreamPresenceContextValue>(
() => ({
onlineHumanIds,
composingUsers,
startComposing,
stopComposing,
}),
[onlineHumanIds, composingUsers, startComposing, stopComposing],
);
return (
<StreamPresenceContext.Provider value={value}>
{children}
</StreamPresenceContext.Provider>
);
}
function useStreamPresenceContext() {
const ctx = useContext(StreamPresenceContext);
if (!ctx) {
throw new Error(
"useStreamPresence must be used within a StreamPresenceProvider",
);
}
return ctx;
}
export function useStreamPresence() {
const { onlineHumanIds } = useStreamPresenceContext();
return { onlineHumanIds };
}
export function useStreamComposing() {
const { composingUsers } = useStreamPresenceContext();
return { composingUsers };
}
export function useStreamComposingBroadcast() {
const { startComposing, stopComposing } = useStreamPresenceContext();
return { startComposing, stopComposing };
}
@@ -0,0 +1,28 @@
import { createContext, useContext, type ReactNode } from "react";
interface StreamSafeArea {
/** Pixels from the screen top reserved for the segmented bar + metadata. */
top: number;
/** Pixels from the screen bottom reserved for compose dock + pills. */
bottom: number;
}
const Ctx = createContext<StreamSafeArea>({ top: 0, bottom: 0 });
/**
* Lets particle views know how much vertical space the chrome reserves so
* scrollable content (long text, future inboxes) doesn't slip under the
* segmented bar / compose dock. Defaults to 0/0 so views work outside the
* StreamView shell (e.g. in a preview).
*/
export function StreamSafeAreaProvider({
top,
bottom,
children,
}: StreamSafeArea & { children: ReactNode }) {
return <Ctx.Provider value={{ top, bottom }}>{children}</Ctx.Provider>;
}
export function useStreamSafeArea(): StreamSafeArea {
return useContext(Ctx);
}
@@ -0,0 +1,181 @@
import { useMemo, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { Globe, X } from "lucide-react-native";
import { toast } from "sonner-native";
import { ComposeDock } from "@/features/compose/ComposeDock";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { generateRandomName } from "@/lib/random-name";
import { createStreamWithFirstParticle } from "@/lib/upload";
import { toUserMessage } from "@/lib/errors";
import { useAuthStore } from "@/stores/auth-store";
import type { RootStackScreenProps } from "@/navigation/types";
const STREAM_NAME_MAX = 60;
/**
* Top-level stream creation. The user names the stream and composes the first
* particle in one screen — desktop's `compose-overlay → ConfigureStreamStep`
* flow collapsed into a touch-native single page.
*
* Visibility is locked to "everyone in the network" in v1; specific-people
* picker is a follow-up (PRD §12.5). Even so, the data path uses the full
* `visible_to` array so plumbing the picker later is purely additive.
*/
export function NewStreamScreen({
route,
navigation,
}: RootStackScreenProps<"NewStream">) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
// Random suggestion is generated once per mount so it's stable across
// renders. The user can edit it freely; on submit we use whatever is in
// the input, with the suggestion as a fallback.
const suggestion = useMemo(() => generateRandomName(), []);
const [name, setName] = useState("");
const effectiveName = name.trim() || suggestion;
// v1: everyone in the network. The data shape supports per-human ids
// via "human:{id}" entries — easy to extend.
const visibleTo = useMemo(() => [`network:${networkId}`], [networkId]);
const handleStreamCreated = (streamId: string) => {
// Replace the stack so back doesn't take the user to an empty new-stream
// screen — instead they go all the way back to the stream list.
navigation.replace("StreamView", { networkId, streamId });
};
const submitText = async (content: string) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: { type: "text", content },
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const submitMedia = async ({
fileUri,
mimeType,
durationMs,
source,
}: {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
}) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: {
type: "media",
fileUri,
mimeType,
durationMs,
source,
},
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
// The dock writes to this path only via the override callbacks above —
// the `targetPath` is unused in that mode but the prop is required, so we
// pass a placeholder rooted at the network.
const placeholderPath = particlePath(networkId, []);
return (
<View className="flex-1 bg-black">
<StatusBar style="light" />
<SafeAreaView edges={["top"]}>
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
accessibilityLabel="Cancel"
>
<X color="white" size={22} strokeWidth={1.8} />
</Pressable>
<Text className="text-white text-base font-semibold">
New stream
</Text>
<View style={{ width: 22 }} />
</View>
</SafeAreaView>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-1 px-6 pt-4">
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
Name
</Text>
<TextInput
value={name}
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
placeholder={suggestion}
placeholderTextColor="rgba(255,255,255,0.35)"
autoCapitalize="none"
autoCorrect={false}
maxLength={STREAM_NAME_MAX}
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
/>
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
Visible to
</Text>
<View className="bg-white/10 rounded-xl px-4 py-3 flex-row items-center gap-3">
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
<Text className="text-white text-base flex-1">
Everyone in {network?.name ?? "this network"}
</Text>
</View>
<View className="mt-6 px-1">
<Text className="text-white/50 text-sm">
Hold the button below to record a voice or video message that's
the first particle in your new stream.
</Text>
</View>
</View>
</KeyboardAvoidingView>
<ComposeDock
networkId={networkId}
targetPath={placeholderPath}
silentPresence
submitMedia={submitMedia}
submitText={submitText}
/>
</View>
);
}
@@ -97,7 +97,7 @@ export const StreamCard = memo(function StreamCard({
onPress={onPress}
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
className={cn(
"bg-card rounded-xl border px-3.5 py-3 flex-row items-center gap-3 active:bg-accent",
"bg-card border-b px-3.5 py-3 flex-row items-center gap-3 active:bg-accent",
isUnseen ? "border-primary" : "border-border",
)}
>
@@ -2,15 +2,13 @@ import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { toUserMessage } from "@/lib/errors";
import { particlePath } from "@/lib/particle-path";
import { useNetwork, useNetworks } from "@/hooks/use-networks";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import type { RootStackScreenProps } from "@/navigation/types";
import { StreamCard } from "./StreamCard";
@@ -21,15 +19,9 @@ export function StreamListScreen({
}: RootStackScreenProps<"StreamList">) {
const { networkId } = route.params;
const network = useNetwork(networkId);
// Re-fetching the network list is the closest stand-in for a hard refresh —
// Firestore subscriptions are already realtime, so pull-to-refresh mainly
// reassures the user and re-tries network metadata.
const { refetch: refetchNetworks, isRefetching: isRefetchingNetworks } =
useNetworks();
const path = particlePath(networkId, []);
// v1: only show open streams. Closed streams are reachable on desktop —
// an "Archived" surface is a follow-up (PRD §20).
const { streams, isLoading, error } = useStreamParticles(path, {
status: "open",
});
@@ -51,13 +43,7 @@ export function StreamListScreen({
<FlatList
data={streams}
keyExtractor={(s) => s.id}
contentContainerClassName="p-3 gap-2 pb-24"
refreshControl={
<RefreshControl
refreshing={isRefetchingNetworks}
onRefresh={() => refetchNetworks()}
/>
}
contentContainerClassName=""
renderItem={({ item }) => (
<StreamCard
particle={item}
@@ -74,10 +60,7 @@ export function StreamListScreen({
)}
<ComposeFab
onPress={() => {
// Real compose flow lands in step 5.
toast("New stream — coming soon");
}}
onPress={() => navigation.navigate("NewStream", { networkId })}
/>
</SafeAreaView>
);
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useState } from "react";
import { usePusherClient } from "@/lib/pusher-provider";
import type { ChannelMessage } from "@/lib/pusher-client";
interface UseChannelResult {
/** Current set of humanIds present in the channel */
presence: string[];
/** Messages received on this channel (since the hook mounted) */
messages: ChannelMessage[];
/** Send a message to the channel */
sendMessage: (payload: unknown) => void;
}
/**
* Subscribe to a pusher channel. Manages presence tracking and message delivery.
* Subscribes on mount, unsubscribes on unmount.
*
* @param channelId - The channel to subscribe to, or null to skip.
*/
export function useChannel(channelId: string | null): UseChannelResult {
const client = usePusherClient();
const [presence, setPresence] = useState<string[]>([]);
const [messages, setMessages] = useState<ChannelMessage[]>([]);
useEffect(() => {
if (!client || !channelId) {
setPresence([]);
setMessages([]);
return;
}
client.subscribe(channelId);
const onSubscribed = (msg: { presence?: string[] }) => {
setPresence(msg.presence ?? []);
};
const onJoin = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) =>
prev.includes(msg.humanId!) ? prev : [...prev, msg.humanId!],
);
}
};
const onLeave = (msg: { humanId?: string }) => {
if (msg.humanId) {
setPresence((prev) => prev.filter((id) => id !== msg.humanId));
}
};
const onMessage = (msg: { humanId?: string; payload?: unknown }) => {
if (msg.humanId) {
setMessages((prev) => [
...prev,
{ humanId: msg.humanId!, payload: msg.payload },
]);
}
};
client.on(channelId, "subscribed", onSubscribed);
client.on(channelId, "join", onJoin);
client.on(channelId, "leave", onLeave);
client.on(channelId, "message", onMessage);
return () => {
client.off(channelId, "subscribed", onSubscribed);
client.off(channelId, "join", onJoin);
client.off(channelId, "leave", onLeave);
client.off(channelId, "message", onMessage);
client.unsubscribe(channelId);
};
}, [client, channelId]);
const sendMessage = useCallback(
(payload: unknown) => {
if (client && channelId) {
client.sendMessage(channelId, payload);
}
},
[client, channelId],
);
return { presence, messages, sendMessage };
}
+286
View File
@@ -0,0 +1,286 @@
/**
* PusherClient manages a WebSocket connection to the pusher service.
* Handles authentication, reconnection with exponential backoff, channel
* subscriptions, and event dispatching.
*
* Identical behavior to the desktop client (js/desktop/src/lib/pusher-client.ts).
* React Native ships a WebSocket polyfill, so this code runs unchanged.
*/
import { logError, reportError } from "@/lib/errors";
export type ConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "reconnecting";
export interface ChannelMessage {
humanId: string;
payload: unknown;
}
interface ServerMessage {
type: "subscribed" | "join" | "leave" | "message" | "error";
channel?: string;
humanId?: string;
presence?: string[];
payload?: unknown;
message?: string;
}
type ChannelEventType = "subscribed" | "join" | "leave" | "message";
type ChannelEventCallback = (msg: ServerMessage) => void;
interface PusherClientConfig {
url: string;
getToken: () => string | null;
}
const INITIAL_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const PING_INTERVAL = 20000; // 20s — keeps alive through GKE gateway timeout
export class PusherClient {
private config: PusherClientConfig;
private ws: WebSocket | null = null;
private state: ConnectionState = "disconnected";
private stateListeners = new Set<(state: ConnectionState) => void>();
private listeners = new Map<
string,
Map<ChannelEventType, Set<ChannelEventCallback>>
>();
private activeSubscriptions = new Set<string>();
private reconnectDelay = INITIAL_RECONNECT_DELAY;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = false;
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(config: PusherClientConfig) {
this.config = config;
}
get connectionState(): ConnectionState {
return this.state;
}
connect(): void {
if (this.ws) return;
const token = this.config.getToken();
if (!token) {
console.warn("[pusher] no token available, cannot connect");
return;
}
this.shouldReconnect = true;
this.setState(
this.state === "reconnecting" ? "reconnecting" : "connecting",
);
const url = `${this.config.url}?token=${encodeURIComponent(token)}`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.setState("connected");
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
this.startPing();
this.resubscribeAll();
};
this.ws.onclose = () => {
this.cleanup();
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = (event) => {
// onclose fires after onerror — reconnection is handled there.
logError(event, { scope: "pusher.ws" });
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
}
disconnect(): void {
this.shouldReconnect = false;
this.clearReconnectTimer();
this.cleanup();
this.activeSubscriptions.clear();
this.setState("disconnected");
}
subscribe(channelId: string): void {
this.activeSubscriptions.add(channelId);
this.send({ type: "subscribe", channel: channelId });
}
unsubscribe(channelId: string): void {
this.activeSubscriptions.delete(channelId);
this.send({ type: "unsubscribe", channel: channelId });
}
sendMessage(channelId: string, payload: unknown): void {
this.send({ type: "message", channel: channelId, payload });
}
on(
channelId: string,
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
if (!this.listeners.has(channelId)) {
this.listeners.set(channelId, new Map());
}
const channelListeners = this.listeners.get(channelId)!;
if (!channelListeners.has(event)) {
channelListeners.set(event, new Set());
}
channelListeners.get(event)!.add(callback);
}
off(
channelId: string,
event: ChannelEventType,
callback: ChannelEventCallback,
): void {
const channelListeners = this.listeners.get(channelId);
if (!channelListeners) return;
const eventListeners = channelListeners.get(event);
if (!eventListeners) return;
eventListeners.delete(callback);
if (eventListeners.size === 0) channelListeners.delete(event);
if (channelListeners.size === 0) this.listeners.delete(channelId);
}
onStateChange(callback: (state: ConnectionState) => void): () => void {
this.stateListeners.add(callback);
return () => {
this.stateListeners.delete(callback);
};
}
// --- Private ---
private send(msg: {
type: string;
channel?: string;
payload?: unknown;
}): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private handleMessage(data: string): void {
if (data === "pong") return;
let msg: ServerMessage;
try {
msg = JSON.parse(data);
} catch (err) {
logError(err, { scope: "pusher.parse", data });
return;
}
if (msg.type === "error") {
logError(new Error(msg.message ?? "pusher server error"), {
scope: "pusher.server",
});
return;
}
if (!msg.channel) return;
const channelListeners = this.listeners.get(msg.channel);
if (!channelListeners) return;
const eventListeners = channelListeners.get(msg.type as ChannelEventType);
if (!eventListeners) return;
for (const cb of eventListeners) {
try {
cb(msg);
} catch (err) {
reportError(err, { scope: "pusher.listener", channel: msg.channel });
}
}
}
private resubscribeAll(): void {
for (const channelId of this.activeSubscriptions) {
this.send({ type: "subscribe", channel: channelId });
}
}
private scheduleReconnect(): void {
this.setState("reconnecting");
const jitter = Math.random() * 0.5 + 0.75;
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
this.reconnectTimer = setTimeout(() => {
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
MAX_RECONNECT_DELAY,
);
this.connect();
}, delay);
}
private cleanup(): void {
this.stopPing();
if (this.ws) {
this.ws.onopen = null;
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.onmessage = null;
if (
this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING
) {
this.ws.close();
}
this.ws = null;
}
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private startPing(): void {
this.stopPing();
this.pingTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send("ping");
}
}, PING_INTERVAL);
}
private stopPing(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private setState(state: ConnectionState): void {
if (this.state === state) return;
this.state = state;
for (const cb of this.stateListeners) {
cb(state);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { PusherClient, type ConnectionState } from "./pusher-client";
import { useSessionStore } from "@/stores/session-store";
import { appConfig } from "@/config/env";
const PusherContext = createContext<PusherClient | null>(null);
const PusherStateContext = createContext<ConnectionState>("disconnected");
export function PusherProvider({ children }: { children: ReactNode }) {
const token = useSessionStore((s) => s.token);
const clientRef = useRef<PusherClient | null>(null);
const [connectionState, setConnectionState] =
useState<ConnectionState>("disconnected");
useEffect(() => {
if (!token) {
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
setConnectionState("disconnected");
}
return;
}
const client = new PusherClient({
url: appConfig.pusherUrl,
getToken: () => useSessionStore.getState().token,
});
clientRef.current = client;
const unsubscribeState = client.onStateChange((state) => {
setConnectionState(state);
});
client.connect();
return () => {
unsubscribeState();
client.disconnect();
clientRef.current = null;
};
}, [token]);
return (
<PusherContext.Provider value={clientRef.current}>
<PusherStateContext.Provider value={connectionState}>
{children}
</PusherStateContext.Provider>
</PusherContext.Provider>
);
}
export function usePusherClient(): PusherClient | null {
return useContext(PusherContext);
}
export function usePusherConnectionState(): ConnectionState {
return useContext(PusherStateContext);
}
+19
View File
@@ -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}`;
}
+201
View File
@@ -0,0 +1,201 @@
import {
FileSystemUploadType,
getInfoAsync,
uploadAsync,
} from "expo-file-system/legacy";
import { apiClient } from "@/api/client";
import {
createParticle,
createStreamParticle,
} from "@/lib/firestore-particles";
import {
particlePath,
toFirestoreChildrenPath,
type ParticlePath,
} from "@/lib/particle-path";
interface UploadMediaParticleParams {
networkId: string;
/** Path of the destination container (stream — possibly with sub-segments). */
targetPath: ParticlePath;
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
createdByHumanId: string;
}
/**
* Upload a recorded file and create the corresponding `media` particle in
* Firestore. Order matches desktop's `use-recorder` flow exactly:
* prepareUpload → PUT → confirmUpload → createParticle.
*
* Returns the new particle's id, or throws on any failure (no half-states —
* if any step fails the caller cancels and reports).
*/
export async function uploadMediaParticle({
networkId,
targetPath,
fileUri,
mimeType,
durationMs,
source,
createdByHumanId,
}: UploadMediaParticleParams): Promise<string> {
const info = await getInfoAsync(fileUri);
if (!info.exists || info.size === undefined) {
throw new Error("Recording file disappeared before upload.");
}
const sizeBytes = info.size;
const namePrefix = mimeType.startsWith("audio/") ? "voice" : "video";
const ext = extensionFromMime(mimeType);
const name = `${namePrefix}-${Date.now()}${ext}`;
const { object_id, upload_url, upload_headers } =
await apiClient.prepareUpload({
network_id: networkId,
name,
content_type: mimeType,
content_length: sizeBytes,
});
const uploadResult = await uploadAsync(upload_url, fileUri, {
httpMethod: "PUT",
uploadType: FileSystemUploadType.BINARY_CONTENT,
headers: upload_headers,
});
if (uploadResult.status < 200 || uploadResult.status >= 300) {
throw new Error(
`Upload to depot failed (HTTP ${uploadResult.status}).`,
);
}
await apiClient.confirmUpload(object_id);
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
"media",
{
object_id,
mime_type: mimeType,
duration_ms: durationMs,
size_bytes: sizeBytes,
source,
},
createdByHumanId,
);
}
interface CreateTextParticleParams {
networkId: string;
targetPath: ParticlePath;
content: string;
createdByHumanId: string;
}
export async function createTextParticle({
targetPath,
content,
createdByHumanId,
}: CreateTextParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
"text",
{ content },
createdByHumanId,
);
}
function extensionFromMime(mime: string): string {
if (mime === "video/mp4") return ".mp4";
if (mime === "video/quicktime") return ".mov";
if (mime === "audio/mp4") return ".m4a";
if (mime === "audio/webm") return ".webm";
return "";
}
// Helper kept here so callers can construct a fresh stream's child-path before
// the stream particle has been written.
export function streamChildrenPath(
networkId: string,
streamId: string,
): ParticlePath {
return particlePath(networkId, [streamId]);
}
// --- New-stream flow ---
interface CreateStreamWithFirstParticleParams {
networkId: string;
name: string;
/** ["network:{id}"] for everyone; ["human:{id}", ...] for specific people. */
visibleTo: string[];
createdByHumanId: string;
/** First particle to write into the new stream. Required — empty streams are not useful. */
firstParticle:
| { type: "text"; content: string }
| {
type: "media";
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
};
}
interface CreateStreamWithFirstParticleResult {
streamId: string;
}
/**
* Create a top-level stream particle plus its first child particle, in that
* order. Mirrors desktop's "create new stream" submit path (compose-overlay
* §handleStreamSubmit). On any failure the caller is responsible for retry —
* we don't roll back the stream particle on child failure because Firestore
* doesn't expose a multi-write transaction across these subcollections, and
* an empty stream is harmless (the user can retry composing into it).
*/
export async function createStreamWithFirstParticle({
networkId,
name,
visibleTo,
createdByHumanId,
firstParticle,
}: CreateStreamWithFirstParticleParams): Promise<CreateStreamWithFirstParticleResult> {
// 1. The stream particle goes at the network root.
const rootChildrenPath = toFirestoreChildrenPath(particlePath(networkId, []));
const streamId = await createStreamParticle(
rootChildrenPath,
{ name },
createdByHumanId,
visibleTo,
);
const streamPath = particlePath(networkId, [streamId]);
// 2. The first child goes inside the new stream.
if (firstParticle.type === "text") {
await createTextParticle({
networkId,
targetPath: streamPath,
content: firstParticle.content,
createdByHumanId,
});
} else {
await uploadMediaParticle({
networkId,
targetPath: streamPath,
fileUri: firstParticle.fileUri,
mimeType: firstParticle.mimeType,
durationMs: firstParticle.durationMs,
source: firstParticle.source,
createdByHumanId,
});
}
return { streamId };
}
@@ -4,6 +4,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { SignInScreen } from "@/features/auth/SignInScreen";
import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
import { StreamListScreen } from "@/features/streams/StreamListScreen";
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
import { SettingsScreen } from "@/features/settings/SettingsScreen";
import { AccountScreen } from "@/features/settings/AccountScreen";
@@ -42,6 +43,11 @@ export function RootNavigator() {
component={StreamViewScreen}
options={{ animation: "fade", gestureEnabled: false }}
/>
<Stack.Screen
name="NewStream"
component={NewStreamScreen}
options={{ animation: "slide_from_bottom" }}
/>
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Account" component={AccountScreen} />
</Stack.Navigator>
+1
View File
@@ -7,6 +7,7 @@ export type RootStackParamList = {
NetworkList: undefined;
StreamList: { networkId: string };
StreamView: { networkId: string; streamId: string };
NewStream: { networkId: string };
Settings: undefined;
Account: undefined;
};
+13 -1
View File
@@ -2985,6 +2985,11 @@ expo-asset@~12.0.13:
"@expo/image-utils" "^0.8.8"
expo-constants "~18.0.13"
expo-audio@~1.0.13:
version "1.0.16"
resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.0.16.tgz#6cee98909c5c8b641832e73d944cf0be1c16d4de"
integrity sha512-j7otyjtO+8PVbemoCoRBr2Em0Kv9to3bfz5UpI5tDVVb5gD1dkn7sjv6/W6tWNqx14WLR1Wijh/ACecVv1Py+g==
expo-build-properties@~1.0.10:
version "1.0.10"
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546"
@@ -2993,6 +2998,13 @@ expo-build-properties@~1.0.10:
ajv "^8.11.0"
semver "^7.6.0"
expo-camera@~17.0.8:
version "17.0.10"
resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456"
integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==
dependencies:
invariant "^2.2.4"
expo-constants@~18.0.13:
version "18.0.13"
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"
@@ -3001,7 +3013,7 @@ expo-constants@~18.0.13:
"@expo/config" "~12.0.13"
"@expo/env" "~2.0.8"
expo-file-system@~19.0.22:
expo-file-system@~19.0.16, expo-file-system@~19.0.22:
version "19.0.22"
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
integrity sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==