mobile v0.1 with deployment for ios (#191)
* stage 1: project init * stage 2: skeleton with navigation * step 2.5: streams list * step 4: stream playback experience * step 5-6: compose experience * fix: broken record * transcode media particles to mp4 * build: reproducible go generate * build: rename skaffold module for particle processor worker * infra: increase particle processor worker resources Was dealing with OOM errors * tweaks to mobile * log transcode work * view on desktop placeholder * tweak padding * cap video resolution to save on memory * infra: bump memory limits as insurance * ux improvements * update bundle id for mobile * config for mobile
This commit was merged in pull request #191.
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Alert, Dimensions, Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as Haptics from "expo-haptics";
|
||||
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 { 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 [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 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 "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,
|
||||
],
|
||||
);
|
||||
|
||||
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 ---
|
||||
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)) {
|
||||
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>
|
||||
|
||||
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
||||
compose dock so the record button doesn't cover them. */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0 bottom-0 items-center"
|
||||
style={{ paddingBottom: insets.bottom + COMPOSE_DOCK_HEIGHT }}
|
||||
>
|
||||
{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-3"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<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}
|
||||
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}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user