step 5-6: compose experience
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RootStackParamList = {
|
||||
NetworkList: undefined;
|
||||
StreamList: { networkId: string };
|
||||
StreamView: { networkId: string; streamId: string };
|
||||
NewStream: { networkId: string };
|
||||
Settings: undefined;
|
||||
Account: undefined;
|
||||
};
|
||||
|
||||
@@ -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==
|
||||
|
||||