693 lines
23 KiB
TypeScript
693 lines
23 KiB
TypeScript
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 (
|
|
<StreamPresenceProvider
|
|
networkId={networkId}
|
|
streamId={props.streamParticle.id}
|
|
>
|
|
<StreamViewInner {...props} />
|
|
</StreamPresenceProvider>
|
|
);
|
|
}
|
|
|
|
function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|
const { networkId } = parseParticlePath(path);
|
|
const network = useNetwork(networkId);
|
|
const insets = useSafeAreaInsets();
|
|
const { composingUsers } = useStreamComposing();
|
|
|
|
const { children, currentParticle, currentIndex, status, next, prev } =
|
|
useStreamPlayback(streamParticle, path);
|
|
|
|
const paused = usePlaybackPauseStore(selectIsPaused);
|
|
const 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 (
|
|
<DeletedParticleView
|
|
key={particle.id}
|
|
particle={particle}
|
|
networkId={networkId}
|
|
paused={paused}
|
|
onEnded={next}
|
|
/>
|
|
);
|
|
}
|
|
switch (particle.type) {
|
|
case "text":
|
|
return (
|
|
<TextParticleView
|
|
key={particle.id}
|
|
particle={particle}
|
|
paused={paused}
|
|
onEnded={next}
|
|
onProgress={setProgress}
|
|
/>
|
|
);
|
|
case "media":
|
|
return (
|
|
<MediaParticleView
|
|
key={particle.id}
|
|
particle={particle}
|
|
paused={paused}
|
|
onEnded={next}
|
|
onProgress={setProgress}
|
|
contentFit={videoFit}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<FallbackParticleView
|
|
key={particle.id}
|
|
particle={particle}
|
|
networkId={networkId}
|
|
paused={paused}
|
|
onEnded={next}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// --- Content guards ---
|
|
if (children.length === 0) {
|
|
return (
|
|
<View className="flex-1 bg-black items-center justify-center px-8">
|
|
<StatusBar style="light" hidden />
|
|
<Text className="text-white/70 text-base">
|
|
No particles in this stream yet.
|
|
</Text>
|
|
<Pressable onPress={exit} className="mt-6 px-4 py-2">
|
|
<Text className="text-white/60">Close</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Animated.View style={[{ flex: 1 }, backdropStyle]} className="bg-black">
|
|
<StatusBar style="light" hidden />
|
|
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
|
|
<GestureDetector gesture={composed}>
|
|
<View className="flex-1">
|
|
{/* Particle canvas — fills the whole screen, gesture-aware.
|
|
StreamSafeAreaProvider tells particle views how much space the
|
|
chrome occupies so scrollable content doesn't slip under. */}
|
|
<StreamSafeAreaProvider top={chromeTop} bottom={chromeBottom}>
|
|
<View className="flex-1">
|
|
{/* While composing we fully unmount the particle so the
|
|
underlying expo-video player releases the AVAudioSession.
|
|
Otherwise it contends with expo-camera and crashes the
|
|
app when video recording starts. */}
|
|
{currentParticle && !composing
|
|
? renderParticle(currentParticle)
|
|
: null}
|
|
</View>
|
|
</StreamSafeAreaProvider>
|
|
|
|
{/* Top chrome: segmented bar + metadata. Painted over the canvas
|
|
so the canvas can be edge-to-edge but content gets a safe-area
|
|
gradient to read against. A real linear gradient (vs a flat
|
|
bg-black/40 block) avoids the hard "bar" edge under the chrome. */}
|
|
<View
|
|
pointerEvents="none"
|
|
className="absolute inset-x-0 top-0"
|
|
style={{ height: insets.top + 120 }}
|
|
>
|
|
<Svg width="100%" height="100%">
|
|
<Defs>
|
|
<LinearGradient
|
|
id="streamTopFade"
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<Stop offset="0" stopColor="#000000" stopOpacity="0.55" />
|
|
<Stop offset="1" stopColor="#000000" stopOpacity="0" />
|
|
</LinearGradient>
|
|
</Defs>
|
|
<Rect width="100%" height="100%" fill="url(#streamTopFade)" />
|
|
</Svg>
|
|
</View>
|
|
<View
|
|
pointerEvents="none"
|
|
className="absolute inset-x-0"
|
|
style={{ top: insets.top + 8 }}
|
|
>
|
|
<View className="px-3">
|
|
<PlaybackPageIndicator
|
|
total={children.length}
|
|
current={currentIndex}
|
|
progress={progress}
|
|
paused={paused}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Top status pills: paused + exit countdown. Anchored just below
|
|
the metadata row (avatar + name ≈ 40px tall, starts at
|
|
insets.top + 32) so they share the top chrome real estate
|
|
instead of competing with captions at the bottom. */}
|
|
<View
|
|
pointerEvents="none"
|
|
className="absolute inset-x-0 items-center"
|
|
style={{ top: insets.top + 88 }}
|
|
>
|
|
{paused ? (
|
|
<View className="bg-white/15 rounded-full px-3 py-1">
|
|
<Text className="text-white/90 text-xs font-medium">
|
|
Paused
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
{exitRemainingMs !== null ? (
|
|
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
|
|
<Text className="text-white/90 text-xs font-medium">
|
|
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</GestureDetector>
|
|
|
|
{/* Top metadata + actions row — lifted OUTSIDE the GestureDetector so
|
|
taps on the action cluster aren't claimed by the stream's tap
|
|
gesture (which advances/regresses the playhead). The chain uses
|
|
`box-none` so empty space still falls through to gestures below. */}
|
|
<View
|
|
pointerEvents="box-none"
|
|
className="absolute inset-x-0"
|
|
style={{ top: insets.top + 8 + 24 }}
|
|
>
|
|
<View className="px-4" pointerEvents="box-none">
|
|
<View
|
|
className="flex-row items-start gap-2"
|
|
pointerEvents="box-none"
|
|
>
|
|
<Pressable
|
|
onPress={exit}
|
|
accessibilityLabel="Close stream"
|
|
hitSlop={8}
|
|
className="bg-white/10 active:bg-white/20 h-8 w-8 items-center justify-center rounded-full"
|
|
>
|
|
<ChevronDown color="white" size={18} strokeWidth={2} />
|
|
</Pressable>
|
|
<View className="flex-1" pointerEvents="none">
|
|
<StreamMetadataHeader
|
|
particle={currentParticle}
|
|
network={network ?? null}
|
|
/>
|
|
</View>
|
|
<StreamTopActions
|
|
networkId={networkId}
|
|
streamParticle={streamParticle}
|
|
humans={network?.humans ?? []}
|
|
videoFit={videoFit}
|
|
onToggleVideoFit={() =>
|
|
setVideoFit((v) => (v === "cover" ? "contain" : "cover"))
|
|
}
|
|
onOpenMembers={() => setMembersOpen(true)}
|
|
onOpenActions={() => setActionsOpen(true)}
|
|
showFitToggle={showFitToggle}
|
|
/>
|
|
</View>
|
|
{composingUsers.length > 0 ? (
|
|
<View className="mt-2" pointerEvents="none">
|
|
<ComposingIndicator
|
|
users={composingUsers}
|
|
networkHumans={network?.humans}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
|
centered on the canvas; outside the GestureDetector so each pill
|
|
tap toggles cleanly without competing with the stream advance/back
|
|
taps. Hidden during composing so the camera preview is unobstructed. */}
|
|
{currentParticle &&
|
|
!composing &&
|
|
!isParticleDeleted(currentParticle) &&
|
|
(currentParticle.type === "media" ||
|
|
currentParticle.type === "text") ? (
|
|
<View
|
|
pointerEvents="box-none"
|
|
className="absolute right-3"
|
|
style={{
|
|
top: insets.top + 100,
|
|
bottom: insets.bottom + COMPOSE_DOCK_HEIGHT + 40,
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<ReactionStack
|
|
reactions={reactionsOnCurrent}
|
|
currentHumanId={userId}
|
|
humans={network?.humans}
|
|
onToggle={handleToggleReaction}
|
|
onOpenSheet={openReactions}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
|
iOS's status-bar tap doesn't fight our gestures. */}
|
|
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
|
|
|
{/* Compose dock + recording overlays. Sits above the GestureDetector
|
|
so its hold-FAB pan gesture isn't competed-with by the StreamView
|
|
tap zones. */}
|
|
<ComposeDock networkId={networkId} targetPath={path} />
|
|
|
|
{/* Reaction sheet — slides up over everything, suspends playback
|
|
internally while open. */}
|
|
<ReactionSheet
|
|
open={reactionsOpen}
|
|
onClose={() => setReactionsOpen(false)}
|
|
reactions={reactionsOnCurrent}
|
|
currentHumanId={userId}
|
|
humans={network?.humans}
|
|
onToggle={handleToggleReaction}
|
|
/>
|
|
|
|
<StreamActionsSheet
|
|
open={actionsOpen}
|
|
onClose={() => setActionsOpen(false)}
|
|
onSelect={(action) => void handleStreamAction(action)}
|
|
streamStatus={streamParticle.status ?? "open"}
|
|
isCreator={isCreator}
|
|
canEditParticle={canEditCurrentParticle}
|
|
canDeleteParticle={canDeleteCurrentParticle}
|
|
/>
|
|
|
|
<StreamMembersSheet
|
|
open={membersOpen}
|
|
onClose={() => setMembersOpen(false)}
|
|
networkId={networkId}
|
|
streamParticle={streamParticle}
|
|
isCreator={isCreator}
|
|
/>
|
|
|
|
<RenameStreamSheet
|
|
open={renameOpen}
|
|
onClose={() => setRenameOpen(false)}
|
|
networkId={networkId}
|
|
streamId={streamParticle.id}
|
|
currentName={streamParticle.properties.name}
|
|
/>
|
|
|
|
{editableTextParticle ? (
|
|
<EditParticleSheet
|
|
open={editOpen}
|
|
onClose={() => setEditOpen(false)}
|
|
networkId={networkId}
|
|
streamId={streamParticle.id}
|
|
particleId={editableTextParticle.id}
|
|
currentContent={editableTextParticle.properties.content}
|
|
/>
|
|
) : null}
|
|
</Animated.View>
|
|
</Animated.View>
|
|
);
|
|
}
|