diff --git a/js/desktop/src/api/types.ts b/js/desktop/src/api/types.ts index 42e014a..1ab02d1 100644 --- a/js/desktop/src/api/types.ts +++ b/js/desktop/src/api/types.ts @@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({ size_bytes: z.number(), transcript: TranscriptSchema.optional(), source: z.enum(["camera", "screen"]).optional(), + // Set by the particle processor worker once an iOS-playable MP4/m4a variant + // has been produced from a non-iOS-playable original (e.g. WebM from desktop). + // When present, clients should prefer these over object_id/mime_type for playback. + transcoded_object_id: z.string().optional(), + transcoded_mime_type: z.string().optional(), }); export type MediaProperties = z.infer; diff --git a/js/desktop/src/features/particles/media-particle-view.tsx b/js/desktop/src/features/particles/media-particle-view.tsx index d08b145..3bd8ea8 100644 --- a/js/desktop/src/features/particles/media-particle-view.tsx +++ b/js/desktop/src/features/particles/media-particle-view.tsx @@ -33,12 +33,18 @@ export const MediaParticleView = forwardRef(null); const audioRef = useRef(null); - const isAudio = particle.properties.mime_type?.startsWith("audio/"); + const isAudio = activeMime?.startsWith("audio/"); useImperativeHandle(ref, () => ({ seek(deltaSec: number) { diff --git a/js/mobile/app.config.ts b/js/mobile/app.config.ts index c775ba7..0035fd5 100644 --- a/js/mobile/app.config.ts +++ b/js/mobile/app.config.ts @@ -12,7 +12,7 @@ const config: ExpoConfig = { userInterfaceStyle: "automatic", newArchEnabled: true, splash: { - image: "./assets/splash.png", + image: "./assets/icon.png", resizeMode: "contain", backgroundColor: "#000000", }, @@ -20,10 +20,6 @@ const config: ExpoConfig = { supportsTablet: true, bundleIdentifier: "com.llink.flowy", infoPlist: { - NSCameraUsageDescription: - "Flowy uses your camera to record video messages.", - NSMicrophoneUsageDescription: - "Flowy uses your microphone to record voice and video messages.", ITSAppUsesNonExemptEncryption: false, }, }, @@ -37,6 +33,22 @@ const config: ExpoConfig = { }, ], "expo-secure-store", + [ + "expo-camera", + { + cameraPermission: + "Flowy uses your camera to record video messages.", + microphonePermission: + "Flowy uses your microphone to record voice and video messages.", + }, + ], + [ + "expo-audio", + { + microphonePermission: + "Flowy uses your microphone to record voice messages.", + }, + ], ], experiments: { typedRoutes: false, diff --git a/js/mobile/assets/flowy.icns b/js/mobile/assets/flowy.icns new file mode 100644 index 0000000..310b5ef Binary files /dev/null and b/js/mobile/assets/flowy.icns differ diff --git a/js/mobile/assets/flowy.ico b/js/mobile/assets/flowy.ico new file mode 100644 index 0000000..f097dba Binary files /dev/null and b/js/mobile/assets/flowy.ico differ diff --git a/js/mobile/assets/flowy.iconset/icon_128x128.png b/js/mobile/assets/flowy.iconset/icon_128x128.png new file mode 100644 index 0000000..48bed99 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_128x128.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_128x128@2x.png b/js/mobile/assets/flowy.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..4af3689 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_128x128@2x.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_16x16.png b/js/mobile/assets/flowy.iconset/icon_16x16.png new file mode 100644 index 0000000..a084a02 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_16x16.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_16x16@2x.png b/js/mobile/assets/flowy.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..67ed0d3 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_16x16@2x.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_256x256.png b/js/mobile/assets/flowy.iconset/icon_256x256.png new file mode 100644 index 0000000..4af3689 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_256x256.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_256x256@2x.png b/js/mobile/assets/flowy.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..6b338f5 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_256x256@2x.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_32x32.png b/js/mobile/assets/flowy.iconset/icon_32x32.png new file mode 100644 index 0000000..67ed0d3 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_32x32.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_32x32@2x.png b/js/mobile/assets/flowy.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..d05c053 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_32x32@2x.png differ diff --git a/js/mobile/assets/flowy.iconset/icon_512x512.png b/js/mobile/assets/flowy.iconset/icon_512x512.png new file mode 100644 index 0000000..6b338f5 Binary files /dev/null and b/js/mobile/assets/flowy.iconset/icon_512x512.png differ diff --git a/js/mobile/assets/icon.png b/js/mobile/assets/icon.png new file mode 100644 index 0000000..a4e96a5 Binary files /dev/null and b/js/mobile/assets/icon.png differ diff --git a/js/mobile/assets/icon_green.png b/js/mobile/assets/icon_green.png new file mode 100644 index 0000000..009ff0c Binary files /dev/null and b/js/mobile/assets/icon_green.png differ diff --git a/js/mobile/assets/icon_red.png b/js/mobile/assets/icon_red.png new file mode 100644 index 0000000..da43505 Binary files /dev/null and b/js/mobile/assets/icon_red.png differ diff --git a/js/mobile/assets/images/FLOWY-4.svg b/js/mobile/assets/images/FLOWY-4.svg new file mode 100644 index 0000000..044b26e --- /dev/null +++ b/js/mobile/assets/images/FLOWY-4.svg @@ -0,0 +1,3 @@ + + + diff --git a/js/mobile/assets/sound.wav b/js/mobile/assets/sound.wav new file mode 100644 index 0000000..68abf03 Binary files /dev/null and b/js/mobile/assets/sound.wav differ diff --git a/js/mobile/assets/sounds/click.mp3 b/js/mobile/assets/sounds/click.mp3 new file mode 100644 index 0000000..4fd472c Binary files /dev/null and b/js/mobile/assets/sounds/click.mp3 differ diff --git a/js/mobile/package.json b/js/mobile/package.json index 2a2aa35..dce3b4d 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -5,7 +5,7 @@ "main": "index.ts", "scripts": { "start": "expo start", - "ios": "expo run:ios", + "ios": "expo run:ios --device", "android": "expo run:android", "compile": "tsc --noEmit", "lint": "expo lint" @@ -18,7 +18,10 @@ "@tanstack/react-query": "^5.90.21", "clsx": "^2.1.1", "expo": "~54.0.0", + "expo-audio": "~1.0.13", + "expo-camera": "~17.0.8", "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.16", "expo-haptics": "~15.0.7", "expo-secure-store": "~15.0.8", "expo-video": "~3.0.10", diff --git a/js/mobile/src/App.tsx b/js/mobile/src/App.tsx index 53bc9ae..a65c412 100644 --- a/js/mobile/src/App.tsx +++ b/js/mobile/src/App.tsx @@ -6,6 +6,7 @@ import { NavigationContainer } from "@react-navigation/native"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { Toaster } from "sonner-native"; import { createQueryClient } from "@/lib/query-client"; +import { PusherProvider } from "@/lib/pusher-provider"; import { RootNavigator } from "@/navigation/RootNavigator"; import { useAuthStore } from "@/stores/auth-store"; @@ -21,13 +22,15 @@ export default function App() { return ( - - - - - - - + + + + + + + + + ); diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index 42e014a..1ab02d1 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -123,6 +123,11 @@ export const MediaPropertiesSchema = z.object({ size_bytes: z.number(), transcript: TranscriptSchema.optional(), source: z.enum(["camera", "screen"]).optional(), + // Set by the particle processor worker once an iOS-playable MP4/m4a variant + // has been produced from a non-iOS-playable original (e.g. WebM from desktop). + // When present, clients should prefer these over object_id/mime_type for playback. + transcoded_object_id: z.string().optional(), + transcoded_mime_type: z.string().optional(), }); export type MediaProperties = z.infer; diff --git a/js/mobile/src/components/ComposingIndicator.tsx b/js/mobile/src/components/ComposingIndicator.tsx new file mode 100644 index 0000000..fcc62ea --- /dev/null +++ b/js/mobile/src/components/ComposingIndicator.tsx @@ -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 ( + + {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 ( + + + + {label} + + + ); + })} + + ); +} + +function BouncingDots() { + return ( + + + + + + ); +} + +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 ( + + ); +} diff --git a/js/mobile/src/features/compose/AudioRecordingOverlay.tsx b/js/mobile/src/features/compose/AudioRecordingOverlay.tsx new file mode 100644 index 0000000..027e21a --- /dev/null +++ b/js/mobile/src/features/compose/AudioRecordingOverlay.tsx @@ -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(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 ( + + + + Voice message + + + {elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s + + + + ); +} + +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 ( + + + + + + ); +} + +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 ( + + Slide up to cancel + + ); +} diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx new file mode 100644 index 0000000..fbc13dc --- /dev/null +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -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; + /** Optional: replace the default text-particle creation. */ + submitText?: (content: string) => Promise; +} + +/** + * 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("video"); + const [ui, setUi] = useState({ 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(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(null); + + const ensurePermissions = useCallback( + async (forVideo: boolean): Promise => { + 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 ? ( + + + + 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" ? ( + + ) : ( + + )} + + + + + + + + + + + {ui.kind === "recording" + ? "Release to send · slide up to cancel" + : "Hold to record"} + + + + 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", + )} + > + + + + + ) : null} + + {ui.kind === "warming-up" || ui.kind === "recording" ? ( + ui.mode === "video" ? ( + + ) : ( + + ) + ) : null} + + + + 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 | 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]); +} diff --git a/js/mobile/src/features/compose/ReviewSheet.tsx b/js/mobile/src/features/compose/ReviewSheet.tsx new file mode 100644 index 0000000..e61ba54 --- /dev/null +++ b/js/mobile/src/features/compose/ReviewSheet.tsx @@ -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; + 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 ( + + + {uri ? ( + mode === "audio" ? ( + + + + + + Voice message · {seconds}s + + + Tap send to share, or retake. + + + + + + ) : ( + + ) + ) : null} + + + + + Cancel + + + + + + + + Retake + + + + + {submitting ? "Sending..." : "Send"} + + + + + + + ); +} diff --git a/js/mobile/src/features/compose/TextComposeModal.tsx b/js/mobile/src/features/compose/TextComposeModal.tsx new file mode 100644 index 0000000..dba0ec3 --- /dev/null +++ b/js/mobile/src/features/compose/TextComposeModal.tsx @@ -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; +} + +/** + * 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(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 ( + + + + + + Cancel + + + + {submitting ? "Sending..." : "Send"} + + + + + + + + + + + ); +} diff --git a/js/mobile/src/features/compose/VideoRecordingOverlay.tsx b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx new file mode 100644 index 0000000..697074c --- /dev/null +++ b/js/mobile/src/features/compose/VideoRecordingOverlay.tsx @@ -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(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(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 ( + + + + + + ); +} + +function wait(ms: number): Promise { + 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. */} + + {warming ? ( + + + + Getting ready… + + + ) : ( + + + + REC · {elapsedSec.toString().padStart(2, "0")}s + + + )} + + + {/* Cancel pill — slides in as user drags up. */} + + + + Slide up to cancel + + + + + {/* Tiny duration counter near the FAB. */} + {!warming ? ( + + + {Math.round(elapsedRatio * 60)}s / {MAX_DURATION_S}s + + + ) : 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 ( + + ); +} diff --git a/js/mobile/src/features/stream-view/MediaParticleView.tsx b/js/mobile/src/features/stream-view/MediaParticleView.tsx index a8ee79c..9de0270 100644 --- a/js/mobile/src/features/stream-view/MediaParticleView.tsx +++ b/js/mobile/src/features/stream-view/MediaParticleView.tsx @@ -1,7 +1,12 @@ -import { useEffect } from "react"; -import { Text, View } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Text, View } from "react-native"; import { Mic, Video as VideoIcon } from "lucide-react-native"; +import { useEventListener } from "expo"; +import { useVideoPlayer, VideoView, type VideoPlayerStatus } from "expo-video"; import type { Particle } from "@/api/types"; +import { apiClient } from "@/api/client"; +import { logError } from "@/lib/errors"; +import { useEvent } from "@/hooks/use-event"; type MediaParticle = Extract; @@ -12,43 +17,213 @@ interface MediaParticleViewProps { onProgress: (ratio: number) => void; } -// Step 4 placeholder. Step 5 replaces this with `expo-video` playback for MP4 -// (camera + audio-only mp4) and "View on desktop" for legacy WebM. For now the -// view advances on a 5s timer so the rest of the playback shell is exercisable -// against existing media particles (which would currently be desktop WebM). -const PLACEHOLDER_DURATION_S = 5; -const TICK_MS = 100; +const TICK_MS = 150; +/** + * Plays MP4 / MOV / m4a content via expo-video. Desktop currently records + * WebM, which AVPlayer can't decode; the particle processor worker produces + * an iOS-playable MP4/m4a variant and writes `transcoded_object_id` / + * `transcoded_mime_type` to the particle. While that work is in flight, we + * show a "Processing for mobile…" placeholder and let the Firestore listener + * swap us into the playable state once the worker finishes. + * + * The signed download URL is fetched lazily via apiClient.getParticleDownloadUrl + * (Orion-issued, time-limited). We show a spinner while that resolves, then + * mount the player and report progress via a 150ms tick reading the player's + * currentTime — same model as desktop's MediaParticleView. + */ export function MediaParticleView({ particle, paused, onEnded, onProgress, }: MediaParticleViewProps) { - const isAudio = particle.properties.mime_type.startsWith("audio/"); - const isMp4 = - particle.properties.mime_type === "video/mp4" || - particle.properties.mime_type === "audio/mp4"; + const activeObjectId = + particle.properties.transcoded_object_id ?? particle.properties.object_id; + const activeMime = + particle.properties.transcoded_mime_type ?? particle.properties.mime_type; + const isAudio = activeMime.startsWith("audio/"); + const isPlayable = isPlayableMime(activeMime); + // Reset progress as the active particle changes — independent of playback + // state — so the segmented bar drops back to 0 immediately. useEffect(() => { onProgress(0); }, [particle.id, onProgress]); + if (!isPlayable) { + return ; + } + + return ( + + ); +} + +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(null); + const [resolveError, setResolveError] = useState(null); + + // Fetch the signed download URL once per active object. Orion URLs are + // time-limited — we treat the URL as one-shot for this view's lifetime. + // Re-runs when the worker writes `transcoded_object_id` and the parent + // resolves a new active object id. useEffect(() => { - if (paused) return; - let elapsed = 0; + let cancelled = false; + setSourceUri(null); + setResolveError(null); + apiClient + .getParticleDownloadUrl(activeObjectId) + .then((url) => { + if (!cancelled) setSourceUri(url); + }) + .catch((err) => { + logError(err, { scope: "media.download-url" }); + if (!cancelled) setResolveError(err as Error); + }); + return () => { + cancelled = true; + }; + }, [activeObjectId, particle.id]); + + const player = useVideoPlayer(sourceUri ?? "", (p) => { + p.loop = false; + p.muted = false; + p.timeUpdateEventInterval = 0.15; + }); + + // Drive play/pause from the suspender store. The player itself is forgiving + // about extra play/pause calls so we don't gate this. + useEffect(() => { + if (!sourceUri) return; + if (paused) { + player.pause(); + } else { + player.play(); + } + }, [paused, sourceUri, player]); + + // End-of-clip → advance. We listen to status flips rather than computing + // duration ratios because video duration may be 0 for the first frame or two. + useEventListener(player, "statusChange", ({ status }) => { + if (status === ("idle" satisfies VideoPlayerStatus)) { + // ignored — happens during source swap + } + }); + + const onEndedStable = useEvent(onEnded); + const onProgressStable = useEvent(onProgress); + + // Progress tick: report currentTime / duration each TICK_MS. Bail when the + // player isn't ready yet (duration = 0). + useEffect(() => { + if (!sourceUri || paused) return; const interval = setInterval(() => { - elapsed += TICK_MS / 1000; - const ratio = Math.min(elapsed / PLACEHOLDER_DURATION_S, 1); - onProgress(ratio); - if (ratio >= 1) { + const duration = player.duration; + if (!duration || duration <= 0) return; + const ratio = Math.min(player.currentTime / duration, 1); + onProgressStable(ratio); + if (ratio >= 0.999) { clearInterval(interval); - onEnded(); + onEndedStable(); } }, TICK_MS); return () => clearInterval(interval); - }, [paused, onEnded, onProgress, particle.id]); + }, [sourceUri, paused, player, onEndedStable, onProgressStable]); + if (resolveError) { + return ( + + + Couldn't load this {isAudio ? "voice message" : "video"}. + + + Tap forward to continue. + + + ); + } + + if (!sourceUri) { + return ( + + + + ); + } + + // 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 ( + + + + + + + + + Voice message + + + ); + } + + // 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 ( + + + + ); +} + +// 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 ( @@ -61,11 +236,28 @@ export function MediaParticleView({ {isAudio ? "Voice message" : "Video message"} - - {isMp4 - ? "Playback wires up in step 5." - : "Recorded on desktop — view there until codecs converge."} + + + + Processing for mobile… + + + + This message will play in a moment. ); } + +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" + ); +} diff --git a/js/mobile/src/features/stream-view/ReactionSheet.tsx b/js/mobile/src/features/stream-view/ReactionSheet.tsx new file mode 100644 index 0000000..547c93f --- /dev/null +++ b/js/mobile/src/features/stream-view/ReactionSheet.tsx @@ -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(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 ( + + + + + + + + + + + + {/* Drag handle — affords downward dismissal at a glance. */} + + + + + React + + + + + + + + {/* Existing reactions row — tap a pill to toggle yours. */} + {activeEmojis.length > 0 || activeTextKeys.length > 0 ? ( + + {activeEmojis.map((emoji) => { + const reactors = reactions![emoji]; + const isMine = reactors.includes(currentHumanId); + return ( + 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", + )} + > + {emoji} + + {reactors.length} + + + ); + })} + + {activeTextKeys.map((key) => { + const reactors = reactions![key]; + const isMine = reactors.includes(currentHumanId); + const firstReactor = resolveHumanDisplay( + reactors[0], + humans, + ); + return ( + 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", + )} + > + + + {firstReactor.initials} + + + + {key} + + {reactors.length > 1 ? ( + + {reactors.length} + + ) : null} + + ); + })} + + ) : null} + + {/* Quick-pick emoji palette — six big tappable buttons. */} + + {REACTION_EMOJIS.slice(0, 6).map((emoji) => { + const isMine = + reactions?.[emoji]?.includes(currentHumanId) ?? false; + return ( + 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", + )} + > + {emoji} + + ); + })} + + + {/* Text reaction input — 40-char cap matches desktop. */} + + + 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" + /> + + + + + + + + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index 4866531..61026ac 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -17,7 +17,13 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { isParticleDeleted, type Particle } from "@/api/types"; -import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; +import { + parseParticlePath, + particlePath, + toFirestoreDocPath, + type ParticlePath, +} from "@/lib/particle-path"; +import { toggleParticleReaction } from "@/lib/firestore-particles"; import { useNetwork } from "@/hooks/use-networks"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; @@ -25,8 +31,17 @@ import { selectIsPaused, usePlaybackPauseStore, } from "@/stores/playback-pause-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { ComposeDock } from "@/features/compose/ComposeDock"; +import { ComposingIndicator } from "@/components/ComposingIndicator"; import { PlaybackPageIndicator } from "./PlaybackPageIndicator"; +import { ReactionSheet } from "./ReactionSheet"; import { StreamMetadataHeader } from "./StreamMetadataHeader"; +import { StreamSafeAreaProvider } from "./stream-safe-area"; +import { + StreamPresenceProvider, + useStreamComposing, +} from "./stream-presence-context"; import { TextParticleView } from "./TextParticleView"; import { MediaParticleView } from "./MediaParticleView"; import { DeletedParticleView } from "./DeletedParticleView"; @@ -41,6 +56,9 @@ const PREV_ZONE_RATIO = 0.28; // flick downward fast enough. const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25; const DISMISS_VELOCITY = 900; +// Swipe-up reactions commit thresholds — flick up ~80px or with enough velocity. +const REACTIONS_DISTANCE = 80; +const REACTIONS_VELOCITY = 600; interface StreamViewProps { streamParticle: Particle & { type: "stream" }; @@ -48,22 +66,67 @@ interface StreamViewProps { onExit: () => void; } -export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { +export function StreamView(props: StreamViewProps) { + const { networkId } = parseParticlePath(props.path); + // The presence provider wraps the inner view so any descendant can broadcast + // composing state without re-deriving the channel id. + return ( + + + + ); +} + +function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { const { networkId } = parseParticlePath(path); const network = useNetwork(networkId); const insets = useSafeAreaInsets(); + const { composingUsers } = useStreamComposing(); const { children, currentParticle, currentIndex, status, next, prev } = useStreamPlayback(streamParticle, path); const paused = usePlaybackPauseStore(selectIsPaused); const [progress, setProgress] = useState(0); + const userId = useAuthStore((s) => s.user?.id) ?? ""; // Local hold state drives the "touch-hold" pause suspender. We wrap the JS // setter inside a runOnJS callback dispatched from the worklet thread. const [holdActive, setHoldActive] = useState(false); useSuspendPlayback(holdActive, "touch-hold"); + // Reaction sheet — opens via swipe-up on the canvas. + const [reactionsOpen, setReactionsOpen] = useState(false); + + const reactionsOnCurrent = + currentParticle && !isParticleDeleted(currentParticle) + ? currentParticle.type === "media" || currentParticle.type === "text" + ? currentParticle.reactions + : undefined + : undefined; + + const handleToggleReaction = useCallback( + (key: string) => { + if (!userId || !currentParticle) return; + if (isParticleDeleted(currentParticle)) return; + const docPath = toFirestoreDocPath( + particlePath(networkId, [streamParticle.id, currentParticle.id]), + ); + void toggleParticleReaction( + docPath, + key, + userId, + reactionsOnCurrent, + ); + }, + [userId, currentParticle, networkId, streamParticle.id, reactionsOnCurrent], + ); + + const openReactions = useCallback(() => setReactionsOpen(true), []); + // Reset progress whenever the active particle changes. useEffect(() => { setProgress(0); @@ -93,7 +156,7 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { onExit(); }, [onExit]); - const pan = Gesture.Pan() + const panDown = Gesture.Pan() .activeOffsetY(15) .failOffsetX([-30, 30]) .failOffsetY(-20) @@ -118,6 +181,22 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { } }); + // Swipe-up opens the reaction sheet. Mirror the down pan's discipline — + // fail on horizontal motion so it doesn't fight the tap-zones. + const panUp = Gesture.Pan() + .activeOffsetY(-15) + .failOffsetX([-30, 30]) + .failOffsetY(20) + .onEnd((e) => { + "worklet"; + if ( + e.translationY < -REACTIONS_DISTANCE || + e.velocityY < -REACTIONS_VELOCITY + ) { + runOnJS(openReactions)(); + } + }); + // --- Tap (advance / regress) --- const tap = Gesture.Tap() .maxDuration(180) @@ -146,9 +225,13 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { runOnJS(setHoldActive)(false); }); - // Pan races the tap+longPress combo: vertical drag activates pan and - // cancels the others; otherwise tap and long-press run simultaneously. - const composed = Gesture.Race(pan, Gesture.Simultaneous(tap, longPress)); + // Pan-down (dismiss), pan-up (reactions), and tap+longPress race against + // each other. The first to clear its activeOffsetY wins; the others fail. + const composed = Gesture.Race( + panDown, + panUp, + Gesture.Simultaneous(tap, longPress), + ); const containerStyle = useAnimatedStyle(() => { const opacity = interpolate( @@ -182,6 +265,12 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { // --- End-of-stream countdown --- const exitRemainingMs = useExitCountdown(status, paused, exit); + // Chrome reservations: top = safe-area + segmented bar (3) + gap (12) + + // metadata row (~38) + breathing room (12). Bottom = safe-area + room for + // pause / countdown pills + the compose dock that lands in this same step. + const chromeTop = insets.top + 65; + const chromeBottom = insets.bottom + 96; + // --- Render the active particle --- const renderParticle = (particle: Particle) => { if (isParticleDeleted(particle)) { @@ -250,10 +339,14 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { - {/* Particle canvas — fills the whole screen, gesture-aware. */} - - {currentParticle ? renderParticle(currentParticle) : null} - + {/* Particle canvas — fills the whole screen, gesture-aware. + StreamSafeAreaProvider tells particle views how much space the + chrome occupies so scrollable content doesn't slip under. */} + + + {currentParticle ? renderParticle(currentParticle) : null} + + {/* Top chrome: segmented bar + metadata. Painted over the canvas so the canvas can be edge-to-edge but content gets a safe-area @@ -283,6 +376,14 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { particle={currentParticle} network={network ?? null} /> + {composingUsers.length > 0 ? ( + + + + ) : null} @@ -314,6 +415,22 @@ export function StreamView({ streamParticle, path, onExit }: StreamViewProps) { {/* Safe-area sentinel for top notch — kept outside GestureDetector so iOS's status-bar tap doesn't fight our gestures. */} + + {/* Compose dock + recording overlays. Sits above the GestureDetector + so its hold-FAB pan gesture isn't competed-with by the StreamView + tap zones. */} + + + {/* Reaction sheet — slides up over everything, suspends playback + internally while open. */} + setReactionsOpen(false)} + reactions={reactionsOnCurrent} + currentHumanId={userId} + humans={network?.humans} + onToggle={handleToggleReaction} + /> ); diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx index a5592fd..f4aead1 100644 --- a/js/mobile/src/features/stream-view/TextParticleView.tsx +++ b/js/mobile/src/features/stream-view/TextParticleView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { ScrollView, Text, View } from "react-native"; import type { Particle } from "@/api/types"; import { cn } from "@/lib/utils"; +import { useStreamSafeArea } from "./stream-safe-area"; type TextParticle = Extract; @@ -44,6 +45,7 @@ export function TextParticleView({ const content = particle.properties.content; const durationS = computeReadDuration(content); const elapsedRef = useRef(0); + const safe = useStreamSafeArea(); // Reset when the particle changes. useEffect(() => { @@ -71,7 +73,13 @@ export function TextParticleView({ if (content.length < IMMERSIVE_CHAR_LIMIT) { const style = getImmersiveStyle(content.length); return ( - + @@ -83,9 +91,16 @@ export function TextParticleView({ // Long text: scrollable card so the reader can pace themselves; the // duration timer keeps ticking either way, which is intentional — - // long messages should still auto-advance at the 15s cap. + // long messages should still auto-advance at the 15s cap. Padding is + // pulled from the StreamSafeArea so the card never slips under chrome. return ( - + ; + 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( + 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([]); + const composingMapRef = useRef(new Map()); + 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 | 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( + () => ({ + onlineHumanIds, + composingUsers, + startComposing, + stopComposing, + }), + [onlineHumanIds, composingUsers, startComposing, stopComposing], + ); + + return ( + + {children} + + ); +} + +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 }; +} diff --git a/js/mobile/src/features/stream-view/stream-safe-area.tsx b/js/mobile/src/features/stream-view/stream-safe-area.tsx new file mode 100644 index 0000000..6e27287 --- /dev/null +++ b/js/mobile/src/features/stream-view/stream-safe-area.tsx @@ -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({ 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 {children}; +} + +export function useStreamSafeArea(): StreamSafeArea { + return useContext(Ctx); +} diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx new file mode 100644 index 0000000..1a1fb82 --- /dev/null +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -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 ( + + + + + + navigation.goBack()} + hitSlop={12} + accessibilityLabel="Cancel" + > + + + + New stream + + + + + + + + + Name + + 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" + /> + + + Visible to + + + + + Everyone in {network?.name ?? "this network"} + + + + + + Hold the button below to record a voice or video message — that's + the first particle in your new stream. + + + + + + + + ); +} diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index 09a0330..782d979 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -97,7 +97,7 @@ export const StreamCard = memo(function StreamCard({ onPress={onPress} android_ripple={{ color: "rgba(0,0,0,0.05)" }} className={cn( - "bg-card rounded-xl border px-3.5 py-3 flex-row items-center gap-3 active:bg-accent", + "bg-card border-b px-3.5 py-3 flex-row items-center gap-3 active:bg-accent", isUnseen ? "border-primary" : "border-border", )} > diff --git a/js/mobile/src/features/streams/StreamListScreen.tsx b/js/mobile/src/features/streams/StreamListScreen.tsx index 5c1a534..2f64a9f 100644 --- a/js/mobile/src/features/streams/StreamListScreen.tsx +++ b/js/mobile/src/features/streams/StreamListScreen.tsx @@ -2,15 +2,13 @@ import { ActivityIndicator, FlatList, Pressable, - RefreshControl, Text, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { toast } from "sonner-native"; import { toUserMessage } from "@/lib/errors"; import { particlePath } from "@/lib/particle-path"; -import { useNetwork, useNetworks } from "@/hooks/use-networks"; +import { useNetwork } from "@/hooks/use-networks"; import { useStreamParticles } from "@/hooks/use-stream-particles"; import type { RootStackScreenProps } from "@/navigation/types"; import { StreamCard } from "./StreamCard"; @@ -21,15 +19,9 @@ export function StreamListScreen({ }: RootStackScreenProps<"StreamList">) { const { networkId } = route.params; const network = useNetwork(networkId); - // Re-fetching the network list is the closest stand-in for a hard refresh — - // Firestore subscriptions are already realtime, so pull-to-refresh mainly - // reassures the user and re-tries network metadata. - const { refetch: refetchNetworks, isRefetching: isRefetchingNetworks } = - useNetworks(); const path = particlePath(networkId, []); - // v1: only show open streams. Closed streams are reachable on desktop — - // an "Archived" surface is a follow-up (PRD §20). + const { streams, isLoading, error } = useStreamParticles(path, { status: "open", }); @@ -51,13 +43,7 @@ export function StreamListScreen({ s.id} - contentContainerClassName="p-3 gap-2 pb-24" - refreshControl={ - refetchNetworks()} - /> - } + contentContainerClassName="" renderItem={({ item }) => ( { - // Real compose flow lands in step 5. - toast("New stream — coming soon"); - }} + onPress={() => navigation.navigate("NewStream", { networkId })} /> ); diff --git a/js/mobile/src/hooks/use-channel.ts b/js/mobile/src/hooks/use-channel.ts new file mode 100644 index 0000000..3cbc608 --- /dev/null +++ b/js/mobile/src/hooks/use-channel.ts @@ -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([]); + const [messages, setMessages] = useState([]); + + 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 }; +} diff --git a/js/mobile/src/lib/pusher-client.ts b/js/mobile/src/lib/pusher-client.ts new file mode 100644 index 0000000..99ce04a --- /dev/null +++ b/js/mobile/src/lib/pusher-client.ts @@ -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> + >(); + + private activeSubscriptions = new Set(); + + private reconnectDelay = INITIAL_RECONNECT_DELAY; + private reconnectTimer: ReturnType | null = null; + private shouldReconnect = false; + + private pingTimer: ReturnType | 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); + } + } +} diff --git a/js/mobile/src/lib/pusher-provider.tsx b/js/mobile/src/lib/pusher-provider.tsx new file mode 100644 index 0000000..d458a2e --- /dev/null +++ b/js/mobile/src/lib/pusher-provider.tsx @@ -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(null); +const PusherStateContext = createContext("disconnected"); + +export function PusherProvider({ children }: { children: ReactNode }) { + const token = useSessionStore((s) => s.token); + const clientRef = useRef(null); + const [connectionState, setConnectionState] = + useState("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 ( + + + {children} + + + ); +} + +export function usePusherClient(): PusherClient | null { + return useContext(PusherContext); +} + +export function usePusherConnectionState(): ConnectionState { + return useContext(PusherStateContext); +} diff --git a/js/mobile/src/lib/random-name.ts b/js/mobile/src/lib/random-name.ts new file mode 100644 index 0000000..7053df5 --- /dev/null +++ b/js/mobile/src/lib/random-name.ts @@ -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}`; +} diff --git a/js/mobile/src/lib/upload.ts b/js/mobile/src/lib/upload.ts new file mode 100644 index 0000000..a6a571b --- /dev/null +++ b/js/mobile/src/lib/upload.ts @@ -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 { + 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 { + 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 { + // 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 }; +} diff --git a/js/mobile/src/navigation/RootNavigator.tsx b/js/mobile/src/navigation/RootNavigator.tsx index 4818d9e..0bbf270 100644 --- a/js/mobile/src/navigation/RootNavigator.tsx +++ b/js/mobile/src/navigation/RootNavigator.tsx @@ -4,6 +4,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { SignInScreen } from "@/features/auth/SignInScreen"; import { NetworkListScreen } from "@/features/networks/NetworkListScreen"; import { StreamListScreen } from "@/features/streams/StreamListScreen"; +import { NewStreamScreen } from "@/features/streams/NewStreamScreen"; import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen"; import { SettingsScreen } from "@/features/settings/SettingsScreen"; import { AccountScreen } from "@/features/settings/AccountScreen"; @@ -42,6 +43,11 @@ export function RootNavigator() { component={StreamViewScreen} options={{ animation: "fade", gestureEnabled: false }} /> + diff --git a/js/mobile/src/navigation/types.ts b/js/mobile/src/navigation/types.ts index a6ef175..dd11343 100644 --- a/js/mobile/src/navigation/types.ts +++ b/js/mobile/src/navigation/types.ts @@ -7,6 +7,7 @@ export type RootStackParamList = { NetworkList: undefined; StreamList: { networkId: string }; StreamView: { networkId: string; streamId: string }; + NewStream: { networkId: string }; Settings: undefined; Account: undefined; }; diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index d4f0e4b..fdf793a 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -2985,6 +2985,11 @@ expo-asset@~12.0.13: "@expo/image-utils" "^0.8.8" expo-constants "~18.0.13" +expo-audio@~1.0.13: + version "1.0.16" + resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.0.16.tgz#6cee98909c5c8b641832e73d944cf0be1c16d4de" + integrity sha512-j7otyjtO+8PVbemoCoRBr2Em0Kv9to3bfz5UpI5tDVVb5gD1dkn7sjv6/W6tWNqx14WLR1Wijh/ACecVv1Py+g== + expo-build-properties@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546" @@ -2993,6 +2998,13 @@ expo-build-properties@~1.0.10: ajv "^8.11.0" semver "^7.6.0" +expo-camera@~17.0.8: + version "17.0.10" + resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456" + integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg== + dependencies: + invariant "^2.2.4" + expo-constants@~18.0.13: version "18.0.13" resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d" @@ -3001,7 +3013,7 @@ expo-constants@~18.0.13: "@expo/config" "~12.0.13" "@expo/env" "~2.0.8" -expo-file-system@~19.0.22: +expo-file-system@~19.0.16, expo-file-system@~19.0.22: version "19.0.22" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.22.tgz#8e8f892b2e89a78102b2b90fc1af5bb6bad4f21b" integrity sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==