import { useCallback, useEffect, useState } from "react"; import { Alert, Dimensions, Pressable, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; import * as Haptics from "expo-haptics"; import { ChevronDown } from "lucide-react-native"; import { Gesture, GestureDetector, } from "react-native-gesture-handler"; import Animated, { Extrapolation, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { isParticleDeleted, type Particle } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath, } from "@/lib/particle-path"; import { softDeleteParticle, toggleParticleReaction, updateStreamStatus, } from "@/lib/firestore-particles"; import { toast } from "sonner-native"; import { toUserMessage } from "@/lib/errors"; import { useNetwork } from "@/hooks/use-networks"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useSuspendPlayback } from "@/hooks/use-suspend-playback"; import { selectIsComposing, 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"; import { FallbackParticleView } from "./FallbackParticleView"; import { useExitCountdown } from "./use-exit-countdown"; import { StreamTopActions } from "./StreamTopActions"; import { StreamActionsSheet, type StreamActionId } from "./StreamActionsSheet"; import { StreamMembersSheet } from "./StreamMembersSheet"; import { RenameStreamSheet } from "./RenameStreamSheet"; import { EditParticleSheet } from "./EditParticleSheet"; import { ReactionStack } from "./ReactionStack"; const SCREEN_HEIGHT = Dimensions.get("window").height; // Tap-zone split: left 28% goes back, right 72% goes forward — matching the // asymmetric "Snapchat thumb-zone" so right-handed taps default to forward. const PREV_ZONE_RATIO = 0.28; // Swipe-down dismiss commit thresholds — either move 1/4 of the screen, or // 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; // Approx height of the ComposeDock from the screen bottom (record button stack // + pb-10). Status pills sit just above this so they aren't hidden behind it. const COMPOSE_DOCK_HEIGHT = 50; interface StreamViewProps { streamParticle: Particle & { type: "stream" }; path: ParticlePath; onExit: () => void; } 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 composing = usePlaybackPauseStore(selectIsComposing); 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); // Top-right cluster sheet state. `videoFit` lets the user toggle expo-video's // contentFit for the active media particle when desktop captures of unusual // aspect ratios get cropped uncomfortably under the default `cover` mode. const [actionsOpen, setActionsOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [videoFit, setVideoFit] = useState<"cover" | "contain">("cover"); const isCreator = !!userId && userId === streamParticle.created_by_human_id; const canDeleteCurrentParticle = !!currentParticle && !!userId && currentParticle.created_by_human_id === userId && currentParticle.type !== "stream" && currentParticle.type !== "folder" && !isParticleDeleted(currentParticle); const canEditCurrentParticle = !!currentParticle && !!userId && currentParticle.created_by_human_id === userId && currentParticle.type === "text" && !isParticleDeleted(currentParticle); const editableTextParticle = canEditCurrentParticle && currentParticle && currentParticle.type === "text" ? currentParticle : null; const showFitToggle = !!currentParticle && !isParticleDeleted(currentParticle) && currentParticle.type === "media" && !currentParticle.properties.mime_type.startsWith("audio/"); const handleStreamAction = useCallback( async (action: StreamActionId) => { const streamDocPath = toFirestoreDocPath( particlePath(networkId, [streamParticle.id]), ); switch (action) { case "toggle-status": { try { await updateStreamStatus( streamDocPath, streamParticle.status === "open" ? "closed" : "open", ); } catch (err) { toast.error(toUserMessage(err)); } return; } case "rename": setRenameOpen(true); return; case "members": setMembersOpen(true); return; case "edit-particle": if (!canEditCurrentParticle) return; setEditOpen(true); return; case "delete-particle": { if (!currentParticle || !userId) return; if (!canDeleteCurrentParticle) return; Alert.alert( "Delete this particle?", "This cannot be undone. Other viewers will see a \"deleted\" message in its place.", [ { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", onPress: async () => { try { const docPath = toFirestoreDocPath( particlePath(networkId, [ streamParticle.id, currentParticle.id, ]), ); await softDeleteParticle(docPath, userId); } catch (err) { toast.error(toUserMessage(err)); } }, }, ], ); return; } } }, [ networkId, streamParticle.id, streamParticle.status, currentParticle, userId, canDeleteCurrentParticle, canEditCurrentParticle, ], ); 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); }, [currentParticle?.id]); const handleTap = useCallback( (xRatio: number) => { if (xRatio < PREV_ZONE_RATIO) { if (currentIndex <= 0) { // Soft "thud" — nothing to go back to. void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); return; } prev(); } else { next(); } }, [currentIndex, next, prev], ); // --- Swipe-down dismiss --- const translateY = useSharedValue(0); const screenWidth = Dimensions.get("window").width; const exit = useCallback(() => { onExit(); }, [onExit]); const panDown = Gesture.Pan() .activeOffsetY(15) .failOffsetX([-30, 30]) .failOffsetY(-20) .onUpdate((e) => { "worklet"; translateY.value = Math.max(0, e.translationY); }) .onEnd((e) => { "worklet"; if ( e.translationY > DISMISS_DISTANCE || e.velocityY > DISMISS_VELOCITY ) { translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 }); runOnJS(exit)(); } else { translateY.value = withSpring(0, { damping: 22, stiffness: 220, mass: 0.6, }); } }); // 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) .maxDistance(15) .onEnd((e, success) => { "worklet"; if (!success) return; const ratio = e.x / screenWidth; runOnJS(handleTap)(ratio); }); // --- Long-press (hold-to-pause) --- const longPress = Gesture.LongPress() .minDuration(180) .maxDistance(15) .onStart(() => { "worklet"; runOnJS(setHoldActive)(true); }) .onTouchesUp(() => { "worklet"; runOnJS(setHoldActive)(false); }) .onFinalize(() => { "worklet"; runOnJS(setHoldActive)(false); }); // 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( translateY.value, [0, SCREEN_HEIGHT * 0.5], [1, 0.4], Extrapolation.CLAMP, ); const scale = interpolate( translateY.value, [0, SCREEN_HEIGHT], [1, 0.85], Extrapolation.CLAMP, ); return { transform: [{ translateY: translateY.value }, { scale }], opacity, }; }); const backdropStyle = useAnimatedStyle(() => { const opacity = interpolate( translateY.value, [0, SCREEN_HEIGHT * 0.5], [1, 0.6], Extrapolation.CLAMP, ); return { opacity }; }); // --- End-of-stream countdown --- // Pause the countdown when this screen isn't focused (e.g. a Huddle screen // is mounted on top). Otherwise the timer keeps ticking under the huddle // and goBack() pops the huddle out from under the user. const isFocused = useIsFocused(); const exitRemainingMs = useExitCountdown(status, paused || !isFocused, exit); // Chrome reservations: top = safe-area + segmented bar (3) + gap (12) + // metadata row (~38) + breathing room (12). Bottom = safe-area + compose // dock + breathing room. Pause / countdown pills moved to the top so the // bottom only reserves space for the compose dock now. const chromeTop = insets.top + 65; const chromeBottom = insets.bottom + COMPOSE_DOCK_HEIGHT + 14; // --- Render the active particle --- const renderParticle = (particle: Particle) => { if (isParticleDeleted(particle)) { return ( ); } switch (particle.type) { case "text": return ( ); case "media": return ( ); default: return ( ); } }; // --- Content guards --- if (children.length === 0) { return ( ); } return ( ); }