Files
llink/js/mobile/src/features/stream-view/StreamView.tsx
T

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>
);
}