mobile v0.1 with deployment for ios (#191)

* stage 1: project init

* stage 2: skeleton with navigation

* step 2.5: streams list

* step 4: stream playback experience

* step 5-6: compose experience

* fix: broken record

* transcode media particles to mp4

* build: reproducible go generate

* build: rename skaffold module for particle processor worker

* infra: increase particle processor worker resources

Was dealing with OOM errors

* tweaks to mobile

* log transcode work

* view on desktop placeholder

* tweak padding

* cap video resolution to save on memory

* infra: bump memory limits as insurance

* ux improvements

* update bundle id for mobile

* config for mobile
This commit was merged in pull request #191.
This commit is contained in:
Arjun Patel
2026-04-29 17:39:11 -07:00
committed by GitHub
parent 3a11a82cd3
commit e3461dd5cd
110 changed files with 14682 additions and 22 deletions
@@ -0,0 +1,125 @@
import { useEffect, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Mic } from "lucide-react-native";
import {
RecordingPresets,
setAudioModeAsync,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface AudioRecordingOverlayProps {
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
}
export function AudioRecordingOverlay({
onComplete,
onCancel,
}: AudioRecordingOverlayProps) {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const state = useAudioRecorderState(recorder, 250);
const finalizedRef = useRef(false);
useEffect(() => {
let active = true;
(async () => {
try {
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
await recorder.prepareToRecordAsync();
if (!active) return;
recorder.record();
} catch (err) {
logError(err, { scope: "compose.audio.start" });
if (active) onCancel();
}
})();
return () => {
active = false;
if (!finalizedRef.current) {
finalizedRef.current = true;
recorder.stop().catch(() => {});
}
void setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
}).catch((err) => logError(err, { scope: "compose.audio.exit" }));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const elapsedMs = state.durationMillis ?? 0;
useEffect(() => {
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
void finish("commit");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elapsedMs]);
const finish = async (kind: "commit" | "cancel") => {
if (finalizedRef.current) return;
finalizedRef.current = true;
const durationMs = state.durationMillis ?? 0;
try {
await recorder.stop();
} catch (err) {
logError(err, { scope: "compose.audio.stop" });
}
if (kind === "cancel") {
onCancel();
return;
}
const uri = recorder.uri;
if (!uri) {
onCancel();
return;
}
onComplete({ uri, durationMs });
};
const elapsedSec = Math.floor(elapsedMs / 1000);
return (
<View
style={StyleSheet.absoluteFill}
className="bg-black items-center justify-center px-8"
>
<View 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>
</View>
<Text className="text-white mt-6 text-lg font-semibold">
{state.isRecording ? "Recording" : "Starting…"}
</Text>
<Text className="text-white/60 mt-1 text-sm">
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
</Text>
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable
onPress={() => void finish("cancel")}
accessibilityLabel="Cancel recording"
className="rounded-full bg-white/15 px-6 py-3"
>
<Text className="text-white text-base font-medium">Cancel</Text>
</Pressable>
<Pressable
onPress={() => void finish("commit")}
accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3"
>
<Text className="text-black text-base font-semibold">Stop</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,298 @@
import { useCallback, useEffect, 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 {
useCameraPermissions,
useMicrophonePermissions,
} from "expo-camera";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { useEvent } from "@/hooks/use-event";
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
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 ComposeUiState =
| { kind: "idle" }
| { kind: "recording"; mode: RecordingMode }
| {
kind: "review";
mode: RecordingMode;
uri: string;
durationMs: number;
}
| { kind: "uploading" };
interface SubmitMediaParams {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
}
interface ComposeDockProps {
networkId: string;
targetPath: ParticlePath;
silentPresence?: boolean;
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
submitText?: (content: string) => Promise<void>;
}
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);
const [camPerm, requestCamPerm] = useCameraPermissions();
const [micPerm, requestMicPerm] = useMicrophonePermissions();
// Tell StreamView to fully unmount its expo-video player while we record.
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
const isComposing = ui.kind !== "idle" || textOpen;
useEffect(() => {
setComposing(isComposing);
return () => setComposing(false);
}, [isComposing, setComposing]);
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
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],
);
const startRecording = useEvent(async () => {
if (ui.kind !== "idle") return;
const ok = await ensurePermissions(mode === "video");
if (!ok) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setUi({ kind: "recording", mode });
});
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 };
});
},
[mode],
);
const handleRecordingCancel = useCallback(() => {
setUi({ kind: "idle" });
}, []);
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);
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);
});
const dockHidden =
ui.kind === "review" ||
ui.kind === "uploading" ||
ui.kind === "recording";
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">
<Pressable
onPress={startRecording}
disabled={ui.kind !== "idle"}
accessibilityLabel={`Record ${mode}`}
className="h-20 w-20 items-center justify-center rounded-full bg-white"
>
<View className="h-6 w-6 rounded bg-black" />
</Pressable>
<Text className="text-white/60 mt-2 text-xs">
Tap 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 === "recording" ? (
ui.mode === "video" ? (
<VideoRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
) : (
<AudioRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
)
) : 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}
/>
</>
);
}
function useComposingBroadcast({
ui,
textOpen,
silent,
}: {
ui: ComposeUiState;
textOpen: boolean;
silent: boolean;
}) {
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
try {
broadcast = useStreamComposingBroadcast();
} catch {
broadcast = null;
}
const mode: ComposingMode | null =
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
useEffect(() => {
if (silent || !broadcast) return;
if (mode) {
broadcast.startComposing(mode);
return () => broadcast?.stopComposing();
}
}, [mode, silent, broadcast]);
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
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;
p.audioMixingMode = "mixWithOthers";
});
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}
onRequestClose={onCancel}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<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>
</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
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}
onRequestClose={onClose}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<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>
</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { CameraView } from "expo-camera";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface VideoRecordingOverlayProps {
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
}
export function VideoRecordingOverlay({
onComplete,
onCancel,
}: VideoRecordingOverlayProps) {
const cameraRef = useRef<CameraView>(null);
const [cameraReady, setCameraReady] = useState(false);
const [recording, setRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
return () => {
cancelledRef.current = true;
};
}, []);
const startRecording = async () => {
const cam = cameraRef.current;
if (!cam || recording || !cameraReady) return;
setRecording(true);
startedAtRef.current = Date.now();
let result: { uri: string } | undefined;
try {
result = await cam.recordAsync({ maxDuration: MAX_DURATION_S });
} catch (err) {
if (cancelledRef.current) return;
logError(err, { scope: "compose.video.recordAsync" });
onCancel();
return;
}
if (cancelledRef.current) return;
const durationMs =
startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0;
if (result?.uri) {
onComplete({ uri: result.uri, durationMs });
} else {
onCancel();
}
};
const stopRecording = () => {
cameraRef.current?.stopRecording();
};
const cancel = () => {
cancelledRef.current = true;
if (recording) {
cameraRef.current?.stopRecording();
}
onCancel();
};
useEffect(() => {
if (!recording) return;
const interval = setInterval(() => {
if (startedAtRef.current === null) return;
setElapsedMs(Date.now() - startedAtRef.current);
}, 250);
return () => clearInterval(interval);
}, [recording]);
const elapsedSec = Math.floor(elapsedMs / 1000);
return (
<View style={StyleSheet.absoluteFill} className="bg-black">
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFill}
facing="front"
mode="video"
mute={false}
onCameraReady={() => setCameraReady(true)}
/>
{recording ? (
<View
pointerEvents="none"
className="absolute top-0 left-0 right-0 items-center pt-16"
>
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
<View className="h-2 w-2 rounded-full bg-white" />
<Text className="text-white text-xs font-semibold tracking-wide">
REC · {elapsedSec.toString().padStart(2, "0")}s
</Text>
</View>
</View>
) : null}
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable
onPress={cancel}
accessibilityLabel="Cancel"
className="rounded-full bg-white/15 px-6 py-3"
>
<Text className="text-white text-base font-medium">Cancel</Text>
</Pressable>
{recording ? (
<Pressable
onPress={stopRecording}
accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3"
>
<Text className="text-black text-base font-semibold">Stop</Text>
</Pressable>
) : (
<Pressable
onPress={startRecording}
disabled={!cameraReady}
accessibilityLabel="Start recording"
className={
cameraReady
? "h-20 w-20 items-center justify-center rounded-full bg-white"
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
}
>
<View className="h-16 w-16 rounded-full bg-red-500" />
</Pressable>
)}
</View>
</View>
);
}