step 5-6: compose experience
@@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({
|
|||||||
size_bytes: z.number(),
|
size_bytes: z.number(),
|
||||||
transcript: TranscriptSchema.optional(),
|
transcript: TranscriptSchema.optional(),
|
||||||
source: z.enum(["camera", "screen"]).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>;
|
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,18 @@ export const MediaParticleView = forwardRef<MediaParticleHandle, MediaParticleVi
|
|||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}, ref) {
|
}, 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 { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const isAudio = particle.properties.mime_type?.startsWith("audio/");
|
const isAudio = activeMime?.startsWith("audio/");
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
seek(deltaSec: number) {
|
seek(deltaSec: number) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const config: ExpoConfig = {
|
|||||||
userInterfaceStyle: "automatic",
|
userInterfaceStyle: "automatic",
|
||||||
newArchEnabled: true,
|
newArchEnabled: true,
|
||||||
splash: {
|
splash: {
|
||||||
image: "./assets/splash.png",
|
image: "./assets/icon.png",
|
||||||
resizeMode: "contain",
|
resizeMode: "contain",
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
},
|
},
|
||||||
@@ -20,10 +20,6 @@ const config: ExpoConfig = {
|
|||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: "com.llink.flowy",
|
bundleIdentifier: "com.llink.flowy",
|
||||||
infoPlist: {
|
infoPlist: {
|
||||||
NSCameraUsageDescription:
|
|
||||||
"Flowy uses your camera to record video messages.",
|
|
||||||
NSMicrophoneUsageDescription:
|
|
||||||
"Flowy uses your microphone to record voice and video messages.",
|
|
||||||
ITSAppUsesNonExemptEncryption: false,
|
ITSAppUsesNonExemptEncryption: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -37,6 +33,22 @@ const config: ExpoConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"expo-secure-store",
|
"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: {
|
experiments: {
|
||||||
typedRoutes: false,
|
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",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios --device",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"compile": "tsc --noEmit",
|
"compile": "tsc --noEmit",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
@@ -18,7 +18,10 @@
|
|||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
|
"expo-audio": "~1.0.13",
|
||||||
|
"expo-camera": "~17.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-video": "~3.0.10",
|
"expo-video": "~3.0.10",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { NavigationContainer } from "@react-navigation/native";
|
|||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
|
import { PusherProvider } from "@/lib/pusher-provider";
|
||||||
import { RootNavigator } from "@/navigation/RootNavigator";
|
import { RootNavigator } from "@/navigation/RootNavigator";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
@@ -21,13 +22,15 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SafeAreaProvider>
|
<PusherProvider>
|
||||||
<NavigationContainer>
|
<SafeAreaProvider>
|
||||||
<RootNavigator />
|
<NavigationContainer>
|
||||||
</NavigationContainer>
|
<RootNavigator />
|
||||||
<Toaster />
|
</NavigationContainer>
|
||||||
<StatusBar style="auto" />
|
<Toaster />
|
||||||
</SafeAreaProvider>
|
<StatusBar style="auto" />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</PusherProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({
|
|||||||
size_bytes: z.number(),
|
size_bytes: z.number(),
|
||||||
transcript: TranscriptSchema.optional(),
|
transcript: TranscriptSchema.optional(),
|
||||||
source: z.enum(["camera", "screen"]).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>;
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import { Mic, Video as VideoIcon } from "lucide-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 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" }>;
|
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||||
|
|
||||||
@@ -12,43 +17,213 @@ interface MediaParticleViewProps {
|
|||||||
onProgress: (ratio: number) => void;
|
onProgress: (ratio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4 placeholder. Step 5 replaces this with `expo-video` playback for MP4
|
const TICK_MS = 150;
|
||||||
// (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;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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({
|
export function MediaParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: MediaParticleViewProps) {
|
}: MediaParticleViewProps) {
|
||||||
const isAudio = particle.properties.mime_type.startsWith("audio/");
|
const activeObjectId =
|
||||||
const isMp4 =
|
particle.properties.transcoded_object_id ?? particle.properties.object_id;
|
||||||
particle.properties.mime_type === "video/mp4" ||
|
const activeMime =
|
||||||
particle.properties.mime_type === "audio/mp4";
|
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(() => {
|
useEffect(() => {
|
||||||
onProgress(0);
|
onProgress(0);
|
||||||
}, [particle.id, onProgress]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
let cancelled = false;
|
||||||
let elapsed = 0;
|
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(() => {
|
const interval = setInterval(() => {
|
||||||
elapsed += TICK_MS / 1000;
|
const duration = player.duration;
|
||||||
const ratio = Math.min(elapsed / PLACEHOLDER_DURATION_S, 1);
|
if (!duration || duration <= 0) return;
|
||||||
onProgress(ratio);
|
const ratio = Math.min(player.currentTime / duration, 1);
|
||||||
if (ratio >= 1) {
|
onProgressStable(ratio);
|
||||||
|
if (ratio >= 0.999) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
onEnded();
|
onEndedStable();
|
||||||
}
|
}
|
||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
return () => clearInterval(interval);
|
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 (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-8">
|
<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">
|
<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">
|
<Text className="text-white mt-6 text-lg font-medium">
|
||||||
{isAudio ? "Voice message" : "Video message"}
|
{isAudio ? "Voice message" : "Video message"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-white/60 mt-2 text-sm text-center">
|
<View className="flex-row items-center mt-3">
|
||||||
{isMp4
|
<ActivityIndicator color="white" />
|
||||||
? "Playback wires up in step 5."
|
<Text className="text-white/60 ml-3 text-sm">
|
||||||
: "Recorded on desktop — view there until codecs converge."}
|
Processing for mobile…
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-white/40 mt-2 text-xs text-center">
|
||||||
|
This message will play in a moment.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
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 { useNetwork } from "@/hooks/use-networks";
|
||||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||||
@@ -25,8 +31,17 @@ import {
|
|||||||
selectIsPaused,
|
selectIsPaused,
|
||||||
usePlaybackPauseStore,
|
usePlaybackPauseStore,
|
||||||
} from "@/stores/playback-pause-store";
|
} 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 { PlaybackPageIndicator } from "./PlaybackPageIndicator";
|
||||||
|
import { ReactionSheet } from "./ReactionSheet";
|
||||||
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
||||||
|
import { StreamSafeAreaProvider } from "./stream-safe-area";
|
||||||
|
import {
|
||||||
|
StreamPresenceProvider,
|
||||||
|
useStreamComposing,
|
||||||
|
} from "./stream-presence-context";
|
||||||
import { TextParticleView } from "./TextParticleView";
|
import { TextParticleView } from "./TextParticleView";
|
||||||
import { MediaParticleView } from "./MediaParticleView";
|
import { MediaParticleView } from "./MediaParticleView";
|
||||||
import { DeletedParticleView } from "./DeletedParticleView";
|
import { DeletedParticleView } from "./DeletedParticleView";
|
||||||
@@ -41,6 +56,9 @@ const PREV_ZONE_RATIO = 0.28;
|
|||||||
// flick downward fast enough.
|
// flick downward fast enough.
|
||||||
const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25;
|
const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25;
|
||||||
const DISMISS_VELOCITY = 900;
|
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 {
|
interface StreamViewProps {
|
||||||
streamParticle: Particle & { type: "stream" };
|
streamParticle: Particle & { type: "stream" };
|
||||||
@@ -48,22 +66,67 @@ interface StreamViewProps {
|
|||||||
onExit: () => void;
|
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 { networkId } = parseParticlePath(path);
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { composingUsers } = useStreamComposing();
|
||||||
|
|
||||||
const { children, currentParticle, currentIndex, status, next, prev } =
|
const { children, currentParticle, currentIndex, status, next, prev } =
|
||||||
useStreamPlayback(streamParticle, path);
|
useStreamPlayback(streamParticle, path);
|
||||||
|
|
||||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||||
const [progress, setProgress] = useState(0);
|
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
|
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
||||||
// setter inside a runOnJS callback dispatched from the worklet thread.
|
// setter inside a runOnJS callback dispatched from the worklet thread.
|
||||||
const [holdActive, setHoldActive] = useState(false);
|
const [holdActive, setHoldActive] = useState(false);
|
||||||
useSuspendPlayback(holdActive, "touch-hold");
|
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.
|
// Reset progress whenever the active particle changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
@@ -93,7 +156,7 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
onExit();
|
onExit();
|
||||||
}, [onExit]);
|
}, [onExit]);
|
||||||
|
|
||||||
const pan = Gesture.Pan()
|
const panDown = Gesture.Pan()
|
||||||
.activeOffsetY(15)
|
.activeOffsetY(15)
|
||||||
.failOffsetX([-30, 30])
|
.failOffsetX([-30, 30])
|
||||||
.failOffsetY(-20)
|
.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) ---
|
// --- Tap (advance / regress) ---
|
||||||
const tap = Gesture.Tap()
|
const tap = Gesture.Tap()
|
||||||
.maxDuration(180)
|
.maxDuration(180)
|
||||||
@@ -146,9 +225,13 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
runOnJS(setHoldActive)(false);
|
runOnJS(setHoldActive)(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pan races the tap+longPress combo: vertical drag activates pan and
|
// Pan-down (dismiss), pan-up (reactions), and tap+longPress race against
|
||||||
// cancels the others; otherwise tap and long-press run simultaneously.
|
// each other. The first to clear its activeOffsetY wins; the others fail.
|
||||||
const composed = Gesture.Race(pan, Gesture.Simultaneous(tap, longPress));
|
const composed = Gesture.Race(
|
||||||
|
panDown,
|
||||||
|
panUp,
|
||||||
|
Gesture.Simultaneous(tap, longPress),
|
||||||
|
);
|
||||||
|
|
||||||
const containerStyle = useAnimatedStyle(() => {
|
const containerStyle = useAnimatedStyle(() => {
|
||||||
const opacity = interpolate(
|
const opacity = interpolate(
|
||||||
@@ -182,6 +265,12 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
// --- End-of-stream countdown ---
|
// --- End-of-stream countdown ---
|
||||||
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
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 ---
|
// --- Render the active particle ---
|
||||||
const renderParticle = (particle: Particle) => {
|
const renderParticle = (particle: Particle) => {
|
||||||
if (isParticleDeleted(particle)) {
|
if (isParticleDeleted(particle)) {
|
||||||
@@ -250,10 +339,14 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
|
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
|
||||||
<GestureDetector gesture={composed}>
|
<GestureDetector gesture={composed}>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
{/* Particle canvas — fills the whole screen, gesture-aware. */}
|
{/* Particle canvas — fills the whole screen, gesture-aware.
|
||||||
<View className="flex-1">
|
StreamSafeAreaProvider tells particle views how much space the
|
||||||
{currentParticle ? renderParticle(currentParticle) : null}
|
chrome occupies so scrollable content doesn't slip under. */}
|
||||||
</View>
|
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
|
||||||
|
<View className="flex-1">
|
||||||
|
{currentParticle ? renderParticle(currentParticle) : null}
|
||||||
|
</View>
|
||||||
|
</StreamSafeAreaProvider>
|
||||||
|
|
||||||
{/* Top chrome: segmented bar + metadata. Painted over the canvas
|
{/* Top chrome: segmented bar + metadata. Painted over the canvas
|
||||||
so the canvas can be edge-to-edge but content gets a safe-area
|
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}
|
particle={currentParticle}
|
||||||
network={network ?? null}
|
network={network ?? null}
|
||||||
/>
|
/>
|
||||||
|
{composingUsers.length > 0 ? (
|
||||||
|
<View className="mt-2">
|
||||||
|
<ComposingIndicator
|
||||||
|
users={composingUsers}
|
||||||
|
networkHumans={network?.humans}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -314,6 +415,22 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||||
iOS's status-bar tap doesn't fight our gestures. */}
|
iOS's status-bar tap doesn't fight our gestures. */}
|
||||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
<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>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { ScrollView, Text, View } from "react-native";
|
import { ScrollView, Text, View } from "react-native";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useStreamSafeArea } from "./stream-safe-area";
|
||||||
|
|
||||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export function TextParticleView({
|
|||||||
const content = particle.properties.content;
|
const content = particle.properties.content;
|
||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
|
const safe = useStreamSafeArea();
|
||||||
|
|
||||||
// Reset when the particle changes.
|
// Reset when the particle changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,7 +73,13 @@ export function TextParticleView({
|
|||||||
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
||||||
const style = getImmersiveStyle(content.length);
|
const style = getImmersiveStyle(content.length);
|
||||||
return (
|
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
|
<Text
|
||||||
className={cn("text-white text-center max-w-xl", style.className)}
|
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
|
// Long text: scrollable card so the reader can pace themselves; the
|
||||||
// duration timer keeps ticking either way, which is intentional —
|
// 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 (
|
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
|
<ScrollView
|
||||||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||||
contentContainerClassName="px-5 py-5"
|
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}
|
onPress={onPress}
|
||||||
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
|
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
|
||||||
className={cn(
|
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",
|
isUnseen ? "border-primary" : "border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
RefreshControl,
|
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { toUserMessage } from "@/lib/errors";
|
import { toUserMessage } from "@/lib/errors";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
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 { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
import type { RootStackScreenProps } from "@/navigation/types";
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
import { StreamCard } from "./StreamCard";
|
import { StreamCard } from "./StreamCard";
|
||||||
@@ -21,15 +19,9 @@ export function StreamListScreen({
|
|||||||
}: RootStackScreenProps<"StreamList">) {
|
}: RootStackScreenProps<"StreamList">) {
|
||||||
const { networkId } = route.params;
|
const { networkId } = route.params;
|
||||||
const network = useNetwork(networkId);
|
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, []);
|
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, {
|
const { streams, isLoading, error } = useStreamParticles(path, {
|
||||||
status: "open",
|
status: "open",
|
||||||
});
|
});
|
||||||
@@ -51,13 +43,7 @@ export function StreamListScreen({
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={streams}
|
data={streams}
|
||||||
keyExtractor={(s) => s.id}
|
keyExtractor={(s) => s.id}
|
||||||
contentContainerClassName="p-3 gap-2 pb-24"
|
contentContainerClassName=""
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefetchingNetworks}
|
|
||||||
onRefresh={() => refetchNetworks()}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<StreamCard
|
<StreamCard
|
||||||
particle={item}
|
particle={item}
|
||||||
@@ -74,10 +60,7 @@ export function StreamListScreen({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ComposeFab
|
<ComposeFab
|
||||||
onPress={() => {
|
onPress={() => navigation.navigate("NewStream", { networkId })}
|
||||||
// Real compose flow lands in step 5.
|
|
||||||
toast("New stream — coming soon");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</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 { SignInScreen } from "@/features/auth/SignInScreen";
|
||||||
import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
|
import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
|
||||||
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
||||||
|
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
|
||||||
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
||||||
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
||||||
import { AccountScreen } from "@/features/settings/AccountScreen";
|
import { AccountScreen } from "@/features/settings/AccountScreen";
|
||||||
@@ -42,6 +43,11 @@ export function RootNavigator() {
|
|||||||
component={StreamViewScreen}
|
component={StreamViewScreen}
|
||||||
options={{ animation: "fade", gestureEnabled: false }}
|
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="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Account" component={AccountScreen} />
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type RootStackParamList = {
|
|||||||
NetworkList: undefined;
|
NetworkList: undefined;
|
||||||
StreamList: { networkId: string };
|
StreamList: { networkId: string };
|
||||||
StreamView: { networkId: string; streamId: string };
|
StreamView: { networkId: string; streamId: string };
|
||||||
|
NewStream: { networkId: string };
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2985,6 +2985,11 @@ expo-asset@~12.0.13:
|
|||||||
"@expo/image-utils" "^0.8.8"
|
"@expo/image-utils" "^0.8.8"
|
||||||
expo-constants "~18.0.13"
|
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:
|
expo-build-properties@~1.0.10:
|
||||||
version "1.0.10"
|
version "1.0.10"
|
||||||
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546"
|
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"
|
ajv "^8.11.0"
|
||||||
semver "^7.6.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:
|
expo-constants@~18.0.13:
|
||||||
version "18.0.13"
|
version "18.0.13"
|
||||||
resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"
|
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/config" "~12.0.13"
|
||||||
"@expo/env" "~2.0.8"
|
"@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"
|
version "19.0.22"
|
||||||
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
|
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b"
|
||||||
integrity sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==
|
integrity sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==
|
||||||
|
|||||||