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,52 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Trash2 } from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
||||
// desktop — a beat long enough to read "this was deleted," not so long it
|
||||
// stalls the stream.
|
||||
const TOMBSTONE_DURATION_MS = 2000;
|
||||
|
||||
interface DeletedParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function DeletedParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Trash2 color="rgba(255,255,255,0.4)" size={28} strokeWidth={1.5} />
|
||||
<Text className="text-white/70 mt-3 text-base font-medium">
|
||||
This particle was deleted
|
||||
</Text>
|
||||
{deleter ? (
|
||||
<Text className="text-white/40 mt-1 text-xs">
|
||||
by {deleter.displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||
quest: { icon: ScrollText, label: "Quest" },
|
||||
paper: { icon: BookOpen, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
};
|
||||
|
||||
const PLACEHOLDER_DURATION_MS = 5000;
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: FallbackParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircle,
|
||||
label: particle.type,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, PLACEHOLDER_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
{meta.label}
|
||||
</Text>
|
||||
{title ? (
|
||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white/50 mt-4 text-xs">
|
||||
From {creator.displayName}
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-1 text-xs">View on desktop</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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<Particle, { type: "media" }>;
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: MediaParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
/** "cover" fills the screen (may crop); "contain" fits the whole frame. */
|
||||
contentFit?: "cover" | "contain";
|
||||
}
|
||||
|
||||
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 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,
|
||||
contentFit = "cover",
|
||||
}: MediaParticleViewProps) {
|
||||
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 <ProcessingForMobilePlaceholder isAudio={isAudio} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PlayableMediaView
|
||||
particle={particle}
|
||||
activeObjectId={activeObjectId}
|
||||
isAudio={isAudio}
|
||||
paused={paused}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
contentFit={contentFit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayableMediaView({
|
||||
particle,
|
||||
activeObjectId,
|
||||
isAudio,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
contentFit,
|
||||
}: {
|
||||
particle: MediaParticle;
|
||||
activeObjectId: string;
|
||||
isAudio: boolean;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
contentFit: "cover" | "contain";
|
||||
}) {
|
||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||
const [resolveError, setResolveError] = useState<Error | null>(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(() => {
|
||||
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;
|
||||
// Don't take exclusive ownership of the iOS AVAudioSession. Without this
|
||||
// the player blocks expo-camera from acquiring the session for video
|
||||
// recording (audio works because expo-audio deactivates other sessions
|
||||
// natively before claiming the session).
|
||||
p.audioMixingMode = "mixWithOthers";
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
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);
|
||||
onEndedStable();
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [sourceUri, paused, player, onEndedStable, onProgressStable]);
|
||||
|
||||
if (resolveError) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-white/80 text-base text-center">
|
||||
Couldn't load this {isAudio ? "voice message" : "video"}.
|
||||
</Text>
|
||||
<Text className="text-white/50 text-sm text-center mt-2">
|
||||
Tap forward to continue.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sourceUri) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<ActivityIndicator color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View
|
||||
className="absolute"
|
||||
style={{ width: 0, height: 0, opacity: 0 }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<VideoView
|
||||
style={{ width: 1, height: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
</View>
|
||||
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
|
||||
<Mic color="white" size={36} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
Voice message
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Video: full-bleed. Default cover so portrait mobile captures fill the
|
||||
// screen; the user can flip to contain via the top-right toggle when desktop
|
||||
// captures at odd aspect ratios get cropped uncomfortably.
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
<VideoView
|
||||
style={{ flex: 1 }}
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit={contentFit}
|
||||
allowsFullscreen={false}
|
||||
allowsPictureInPicture={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
|
||||
{isAudio ? (
|
||||
<Mic color="white" size={36} strokeWidth={1.5} />
|
||||
) : (
|
||||
<VideoIcon color="white" size={36} strokeWidth={1.5} />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
{isAudio ? "Voice message" : "Video message"}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-3">
|
||||
<Text className="text-white/60 ml-3 text-sm">
|
||||
View on desktop
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white/40 mt-2 text-xs text-center">
|
||||
Please view this on desktop only.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface PlaybackPageIndicatorProps {
|
||||
total: number;
|
||||
current: number;
|
||||
/** 0–1 progress for the active segment. Source ticks at ~100ms. */
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
const SEGMENT_GAP = 3;
|
||||
const SEGMENT_HEIGHT = 2.5;
|
||||
const SMOOTHING_MS = 300;
|
||||
|
||||
/**
|
||||
* Snapchat-style segmented progress bar. Past segments full, future empty,
|
||||
* active segment animated. The 300ms linear smoothing absorbs the 100ms
|
||||
* tick from the particle view source so motion looks continuous at 60fps.
|
||||
*/
|
||||
export function PlaybackPageIndicator({
|
||||
total,
|
||||
current,
|
||||
progress,
|
||||
paused,
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<Segment
|
||||
key={i}
|
||||
isActive={i === current}
|
||||
isPast={i < current}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
isActive: boolean;
|
||||
isPast: boolean;
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
||||
// Each segment owns its own width animation. Past = 1, future = 0,
|
||||
// active = animated toward `progress`. Reanimated keeps the tween on the
|
||||
// UI thread so JS thread stalls (e.g. the 100ms text tick re-render)
|
||||
// can't drop frames here.
|
||||
const fill = useSharedValue(isPast ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPast) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
||||
return;
|
||||
}
|
||||
if (!isActive) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = 0;
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
cancelAnimation(fill);
|
||||
return;
|
||||
}
|
||||
fill.value = withTiming(progress, {
|
||||
duration: SMOOTHING_MS,
|
||||
easing: Easing.linear,
|
||||
});
|
||||
}, [isPast, isActive, progress, paused, fill]);
|
||||
|
||||
const fillStyle = useAnimatedStyle(() => ({
|
||||
width: `${Math.min(Math.max(fill.value, 0), 1) * 100}%`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 overflow-hidden rounded-full bg-white/30"
|
||||
style={{ height: SEGMENT_HEIGHT }}
|
||||
>
|
||||
<Animated.View
|
||||
className="h-full bg-white/95 rounded-full"
|
||||
style={fillStyle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
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<string>(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 (
|
||||
<Modal
|
||||
visible={mounted}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={dismiss}
|
||||
>
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Animated.View
|
||||
pointerEvents={open ? "auto" : "none"}
|
||||
style={[
|
||||
{ position: "absolute", inset: 0, backgroundColor: "black" },
|
||||
backdropStyle,
|
||||
]}
|
||||
>
|
||||
<Pressable style={{ flex: 1 }} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1, justifyContent: "flex-end" }}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<GestureDetector gesture={sheetPan}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: "#1c1c1c",
|
||||
borderTopLeftRadius: 22,
|
||||
borderTopRightRadius: 22,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sheetStyle,
|
||||
]}
|
||||
>
|
||||
<SafeAreaView edges={["bottom"]}>
|
||||
<View className="px-5 pt-3 pb-2 items-center">
|
||||
{/* Drag handle — affords downward dismissal at a glance. */}
|
||||
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
||||
|
||||
<View className="flex-row items-center justify-between w-full">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
React
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={dismiss}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Close reactions"
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Existing reactions row — tap a pill to toggle yours. */}
|
||||
{activeEmojis.length > 0 || activeTextKeys.length > 0 ? (
|
||||
<View className="px-5 pb-3 flex-row flex-wrap gap-2">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions![emoji];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => 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",
|
||||
)}
|
||||
>
|
||||
<Text className="text-base">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-semibold">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((key) => {
|
||||
const reactors = reactions![key];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(
|
||||
reactors[0],
|
||||
humans,
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
key={key}
|
||||
onPress={() => 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",
|
||||
)}
|
||||
>
|
||||
<View className="bg-white/20 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{key}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Quick-pick emoji palette — six big tappable buttons. */}
|
||||
<View className="px-3 pt-2 pb-4 flex-row justify-around">
|
||||
{REACTION_EMOJIS.slice(0, 6).map((emoji) => {
|
||||
const isMine =
|
||||
reactions?.[emoji]?.includes(currentHumanId) ?? false;
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => 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",
|
||||
)}
|
||||
>
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Text reaction input — 40-char cap matches desktop. */}
|
||||
<View className="px-4 pb-4 flex-row items-center gap-2">
|
||||
<View className="flex-1 bg-white/10 rounded-full px-4 py-2.5">
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={(v) => 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"
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={submitText}
|
||||
disabled={text.trim().length === 0}
|
||||
accessibilityLabel="Send text reaction"
|
||||
className={cn(
|
||||
"h-11 w-11 items-center justify-center rounded-full",
|
||||
text.trim().length === 0
|
||||
? "bg-white/10"
|
||||
: "bg-white",
|
||||
)}
|
||||
>
|
||||
<Send
|
||||
color={text.trim().length === 0 ? "rgba(255,255,255,0.3)" : "black"}
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { REACTION_EMOJIS, type Reactions } from "@/api/types";
|
||||
import type { Human } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
|
||||
interface ReactionStackProps {
|
||||
reactions: Reactions;
|
||||
currentHumanId: string;
|
||||
humans: Human[] | undefined;
|
||||
/** Toggle a reaction (emoji or text) — same contract as ReactionSheet's onToggle. */
|
||||
onToggle: (key: string) => void;
|
||||
/** Open the full reaction sheet for emoji + custom-text picking. */
|
||||
onOpenSheet: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-edge reaction stack — mobile counterpart of desktop's ReactionBar.
|
||||
* Sits vertically centered on the right side of the canvas so the user can
|
||||
* see existing reactions at a glance and tap to toggle their own. The "+"
|
||||
* affordance opens the ReactionSheet for the full picker (emoji or text).
|
||||
*/
|
||||
export function ReactionStack({
|
||||
reactions,
|
||||
currentHumanId,
|
||||
humans,
|
||||
onToggle,
|
||||
onOpenSheet,
|
||||
}: ReactionStackProps) {
|
||||
const activeEmojis = REACTION_EMOJIS.filter(
|
||||
(emoji) => reactions?.[emoji] && (reactions[emoji]?.length ?? 0) > 0,
|
||||
);
|
||||
const activeTextKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(reactions ?? {}).filter(
|
||||
(k) => !EMOJI_SET.has(k) && (reactions?.[k]?.length ?? 0) > 0,
|
||||
),
|
||||
[reactions],
|
||||
);
|
||||
|
||||
const handleToggle = (key: string) => {
|
||||
void Haptics.selectionAsync();
|
||||
onToggle(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="items-end gap-1.5">
|
||||
{activeEmojis.map((emoji) => {
|
||||
const reactors = reactions?.[emoji] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
return (
|
||||
<Pressable
|
||||
key={emoji}
|
||||
onPress={() => handleToggle(emoji)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1 rounded-full px-2 py-1",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
)}
|
||||
style={
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text className="text-sm">{emoji}</Text>
|
||||
<Text className="text-white/85 text-xs font-medium">
|
||||
{reactors.length}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeTextKeys.map((text) => {
|
||||
const reactors = reactions?.[text] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||
return (
|
||||
<Pressable
|
||||
key={text}
|
||||
onPress={() => handleToggle(text)}
|
||||
className={cn(
|
||||
"flex-row items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5",
|
||||
isMine ? "bg-white/25" : "bg-black/45",
|
||||
)}
|
||||
style={[
|
||||
{ maxWidth: 200 },
|
||||
isMine
|
||||
? { borderWidth: 1, borderColor: "rgba(255,255,255,0.4)" }
|
||||
: null,
|
||||
]}
|
||||
>
|
||||
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="text-white/90 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{reactors.length > 1 ? (
|
||||
<Text className="text-white/60 text-xs">{reactors.length}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
<Pressable
|
||||
onPress={onOpenSheet}
|
||||
accessibilityLabel="Add reaction"
|
||||
className="h-8 w-8 items-center justify-center rounded-full bg-black/45"
|
||||
>
|
||||
<Plus color="rgba(255,255,255,0.85)" size={16} strokeWidth={2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { updateParticleProperties } from "@/lib/firestore-particles";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
|
||||
interface RenameStreamSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
streamId: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export function RenameStreamSheet({
|
||||
open,
|
||||
onClose,
|
||||
networkId,
|
||||
streamId,
|
||||
currentName,
|
||||
}: RenameStreamSheetProps) {
|
||||
useSuspendPlayback(open, "rename-stream");
|
||||
|
||||
const [name, setName] = useState(currentName);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, currentName]);
|
||||
|
||||
const trimmed = name.trim();
|
||||
const canSave = !saving && trimmed.length > 0 && trimmed !== currentName;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const docPath = toFirestoreDocPath(particlePath(networkId, [streamId]));
|
||||
await updateParticleProperties<"stream">(docPath, { name: trimmed });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} avoidKeyboard>
|
||||
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<Text className="text-white/70 text-base">Cancel</Text>
|
||||
</Pressable>
|
||||
<Text className="text-white text-base font-semibold">Rename</Text>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!canSave}
|
||||
hitSlop={12}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
canSave ? "text-white" : "text-white/30",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="px-5 pb-6">
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
placeholder="Stream name"
|
||||
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
|
||||
/>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import {
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react-native";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
|
||||
export type StreamActionId =
|
||||
| "toggle-status"
|
||||
| "rename"
|
||||
| "members"
|
||||
| "delete-particle";
|
||||
|
||||
interface StreamActionsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (action: StreamActionId) => void;
|
||||
streamStatus: "open" | "closed";
|
||||
isCreator: boolean;
|
||||
/** True when the *current* particle is one this user can soft-delete. */
|
||||
canDeleteParticle: boolean;
|
||||
}
|
||||
|
||||
export function StreamActionsSheet({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
streamStatus,
|
||||
isCreator,
|
||||
canDeleteParticle,
|
||||
}: StreamActionsSheetProps) {
|
||||
const choose = (id: StreamActionId) => {
|
||||
onClose();
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose}>
|
||||
<View className="py-2">
|
||||
<ActionRow
|
||||
icon={
|
||||
streamStatus === "open" ? (
|
||||
<CircleCheckBig color="white" size={20} />
|
||||
) : (
|
||||
<CircleDot color="#22c55e" size={20} />
|
||||
)
|
||||
}
|
||||
label={
|
||||
streamStatus === "open" ? "Close stream" : "Reopen stream"
|
||||
}
|
||||
onPress={() => choose("toggle-status")}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={<Users color="white" size={20} />}
|
||||
label="Members"
|
||||
onPress={() => choose("members")}
|
||||
/>
|
||||
{isCreator ? (
|
||||
<ActionRow
|
||||
icon={<Pencil color="white" size={20} />}
|
||||
label="Rename stream"
|
||||
onPress={() => choose("rename")}
|
||||
/>
|
||||
) : null}
|
||||
{canDeleteParticle ? (
|
||||
<ActionRow
|
||||
icon={<Trash2 color="#ef4444" size={20} />}
|
||||
label="Delete particle"
|
||||
tone="destructive"
|
||||
onPress={() => choose("delete-particle")}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="px-5 pt-2 pb-2">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className="bg-white/10 active:bg-white/15 rounded-xl py-3 items-center"
|
||||
>
|
||||
<Text className="text-white text-base font-semibold">Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
tone = "default",
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
tone?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="px-5 py-3.5 flex-row items-center gap-3 active:bg-white/5"
|
||||
>
|
||||
<View className="w-6 items-center">{icon}</View>
|
||||
<Text
|
||||
className={cn(
|
||||
"text-base",
|
||||
tone === "destructive" ? "text-red-400" : "text-white",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Globe, Lock, X } from "lucide-react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { particlePath, toFirestoreDocPath } from "@/lib/particle-path";
|
||||
import { updateParticleVisibleTo } from "@/lib/firestore-particles";
|
||||
import {
|
||||
buildCustomVisibility,
|
||||
buildNetworkVisibility,
|
||||
parseVisibleTo,
|
||||
} from "@/lib/stream-visibility";
|
||||
import { toUserMessage } from "@/lib/errors";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import { BottomSheet } from "@/components/BottomSheet";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamMembersSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
isCreator: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only-for-non-creators view of who can see the stream, plus an inline
|
||||
* editor for creators to flip between network-wide and per-person and to
|
||||
* add/remove people. Mobile counterpart of stream-members-overlay.tsx.
|
||||
*/
|
||||
export function StreamMembersSheet({
|
||||
open,
|
||||
onClose,
|
||||
networkId,
|
||||
streamParticle,
|
||||
isCreator,
|
||||
}: StreamMembersSheetProps) {
|
||||
useSuspendPlayback(open, "stream-members");
|
||||
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const network = useNetwork(networkId);
|
||||
const humans = network?.humans ?? [];
|
||||
const creatorId = streamParticle.created_by_human_id;
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
|
||||
const docPath = useMemo(
|
||||
() => toFirestoreDocPath(particlePath(networkId, [streamParticle.id])),
|
||||
[networkId, streamParticle.id],
|
||||
);
|
||||
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const memberSet = new Set(memberIds);
|
||||
const availableToAdd = humans.filter((h) => !memberSet.has(h.id));
|
||||
|
||||
const apply = async (next: string[]) => {
|
||||
try {
|
||||
await updateParticleVisibleTo(docPath, next);
|
||||
} catch (err) {
|
||||
toast.error(toUserMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const setNetworkWide = () => apply(buildNetworkVisibility(networkId));
|
||||
const setCustomOnlyCreator = () => apply(buildCustomVisibility([creatorId]));
|
||||
|
||||
const removeMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
if (id === creatorId) return;
|
||||
const next = visibility.humanIds.filter((x) => x !== id);
|
||||
if (next.length === 0) return;
|
||||
void apply(buildCustomVisibility(next));
|
||||
};
|
||||
|
||||
const addMember = (id: string) => {
|
||||
if (visibility.mode !== "custom") return;
|
||||
void apply(buildCustomVisibility([...visibility.humanIds, id]));
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheet open={open} onClose={onClose} maxHeight="85%">
|
||||
<View className="flex-row items-center justify-between px-5 pb-3">
|
||||
<View style={{ width: 22 }} />
|
||||
<Text className="text-white text-base font-semibold">Members</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12}>
|
||||
<X color="rgba(255,255,255,0.7)" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="px-5 pb-3">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||
Visibility
|
||||
</Text>
|
||||
{isCreator ? (
|
||||
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
|
||||
<ModePill
|
||||
active={visibility.mode === "network"}
|
||||
icon={<Globe color="white" size={14} />}
|
||||
label="Network-wide"
|
||||
onPress={setNetworkWide}
|
||||
/>
|
||||
<ModePill
|
||||
active={visibility.mode === "custom"}
|
||||
icon={<Lock color="white" size={14} />}
|
||||
label="Specific people"
|
||||
onPress={setCustomOnlyCreator}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{visibility.mode === "network" ? (
|
||||
<>
|
||||
<Globe color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
Everyone in {network?.name ?? "network"}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock color="rgba(255,255,255,0.5)" size={14} />
|
||||
<Text className="text-white/70 text-sm">
|
||||
{memberIds.length} specific{" "}
|
||||
{memberIds.length === 1 ? "person" : "people"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerClassName="pb-4">
|
||||
<View className="px-5 pt-2">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mb-2">
|
||||
{visibility.mode === "network" ? "Has access" : "People"} ·{" "}
|
||||
{memberIds.length}
|
||||
</Text>
|
||||
{memberIds.map((id) => {
|
||||
const display = resolveHumanDisplay(id, humans);
|
||||
const isCreatorRow = id === creatorId;
|
||||
const canRemove =
|
||||
isCreator && visibility.mode === "custom" && !isCreatorRow;
|
||||
return (
|
||||
<View
|
||||
key={id}
|
||||
className="flex-row items-center gap-3 py-2.5"
|
||||
>
|
||||
<Avatar
|
||||
humanId={id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
online={onlineHumanIds.has(id)}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={
|
||||
display.exists
|
||||
? "text-white text-sm font-medium"
|
||||
: "text-white/50 italic text-sm font-medium"
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
{display.exists ? (
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.email}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{isCreatorRow ? (
|
||||
<Text className="text-white/30 text-[10px] uppercase tracking-wider">
|
||||
Creator
|
||||
</Text>
|
||||
) : canRemove ? (
|
||||
<Pressable
|
||||
onPress={() => removeMember(id)}
|
||||
hitSlop={10}
|
||||
accessibilityLabel={`Remove ${display.displayName}`}
|
||||
>
|
||||
<X color="rgba(255,255,255,0.6)" size={18} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{isCreator &&
|
||||
visibility.mode === "custom" &&
|
||||
availableToAdd.length > 0 ? (
|
||||
<View className="px-5 pt-4 mt-2 border-t border-white/5">
|
||||
<Text className="text-white/40 text-[10px] font-semibold uppercase tracking-widest mt-3 mb-2">
|
||||
Add people
|
||||
</Text>
|
||||
{availableToAdd.map((human) => {
|
||||
const display = resolveHumanDisplay(human.id, humans);
|
||||
return (
|
||||
<Pressable
|
||||
key={human.id}
|
||||
onPress={() => addMember(human.id)}
|
||||
className="flex-row items-center gap-3 py-2.5 active:bg-white/5 rounded-lg"
|
||||
>
|
||||
<Avatar
|
||||
humanId={human.id}
|
||||
humans={humans}
|
||||
size="sm"
|
||||
online={onlineHumanIds.has(human.id)}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-medium"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white/40 text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.email}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-white/60 text-sm">Add</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePill({
|
||||
active,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className={
|
||||
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2 " +
|
||||
(active ? "bg-white/15" : "")
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
<Text
|
||||
className={
|
||||
active
|
||||
? "text-white text-xs font-semibold"
|
||||
: "text-white/60 text-xs"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { Network, Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamMetadataHeaderProps {
|
||||
particle: Particle | null;
|
||||
network: Network | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar + display name + relative time. Sits below the segmented bar so the
|
||||
* "who/when" answer is always one glance away — Snapchat-style.
|
||||
*/
|
||||
export function StreamMetadataHeader({
|
||||
particle,
|
||||
network,
|
||||
}: StreamMetadataHeaderProps) {
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
if (!particle) return null;
|
||||
const display = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
|
||||
const editedAt =
|
||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
||||
const isOnline = particle.created_by_human_id
|
||||
? onlineHumanIds.has(particle.created_by_human_id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Avatar
|
||||
humanId={particle.created_by_human_id}
|
||||
humans={network?.humans}
|
||||
size="sm"
|
||||
online={isOnline}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<RelativeTimestamp
|
||||
date={particle.created_at}
|
||||
className="text-white/60 text-xs"
|
||||
/>
|
||||
{editedAt ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
· edited{" "}
|
||||
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
|
||||
import type { Human, Particle } from "@/api/types";
|
||||
import { parseVisibleTo } from "@/lib/stream-visibility";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/Avatar";
|
||||
import { useStreamPresence } from "./stream-presence-context";
|
||||
|
||||
interface StreamTopActionsProps {
|
||||
networkId: string;
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
humans: Human[];
|
||||
videoFit: "cover" | "contain";
|
||||
onToggleVideoFit: () => void;
|
||||
onOpenMembers: () => void;
|
||||
onOpenActions: () => void;
|
||||
/** True when current particle is a video — fit toggle hidden otherwise. */
|
||||
showFitToggle: boolean;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 3;
|
||||
|
||||
/**
|
||||
* Top-right cluster on StreamView: visibility avatars (with presence ring),
|
||||
* fit/fill toggle, and actions menu trigger. Mirrors desktop's stream-top-bar
|
||||
* but compact for the mobile chrome.
|
||||
*/
|
||||
export function StreamTopActions({
|
||||
networkId,
|
||||
streamParticle,
|
||||
humans,
|
||||
videoFit,
|
||||
onToggleVideoFit,
|
||||
onOpenMembers,
|
||||
onOpenActions,
|
||||
showFitToggle,
|
||||
}: StreamTopActionsProps) {
|
||||
const { onlineHumanIds } = useStreamPresence();
|
||||
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
||||
const memberIds =
|
||||
visibility.mode === "network"
|
||||
? humans.map((h) => h.id)
|
||||
: visibility.humanIds;
|
||||
const shown = memberIds.slice(0, MAX_AVATARS);
|
||||
const overflow = memberIds.length - shown.length;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Pressable
|
||||
onPress={onOpenMembers}
|
||||
accessibilityLabel="Stream members"
|
||||
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
||||
>
|
||||
{visibility.mode === "network" && memberIds.length === 0 ? (
|
||||
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
||||
) : (
|
||||
<View className="flex-row">
|
||||
{shown.map((id, idx) => (
|
||||
<View
|
||||
key={id}
|
||||
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
||||
>
|
||||
{/* The stack ring matches the chrome's translucent bg so it
|
||||
reads as a separator without painting hard black halos. */}
|
||||
<Avatar
|
||||
humanId={id}
|
||||
humans={humans}
|
||||
size="xs"
|
||||
online={onlineHumanIds.has(id)}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{overflow > 0 ? (
|
||||
<Text className="text-white/70 text-[10px] font-medium ml-0.5">
|
||||
+{overflow}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
|
||||
{showFitToggle ? (
|
||||
<Pressable
|
||||
onPress={onToggleVideoFit}
|
||||
accessibilityLabel={
|
||||
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
||||
}
|
||||
className={cn(
|
||||
"h-8 w-8 items-center justify-center rounded-full",
|
||||
"bg-white/10 active:bg-white/20",
|
||||
)}
|
||||
>
|
||||
{videoFit === "cover" ? (
|
||||
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
onPress={onOpenActions}
|
||||
accessibilityLabel="More actions"
|
||||
className="h-8 w-8 items-center justify-center rounded-full bg-white/10 active:bg-white/20"
|
||||
>
|
||||
<EllipsisVertical color="white" size={16} strokeWidth={1.8} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { StreamView } from "./StreamView";
|
||||
|
||||
export function StreamViewScreen({
|
||||
navigation,
|
||||
route,
|
||||
}: RootStackScreenProps<"StreamView">) {
|
||||
const { networkId, streamId } = route.params;
|
||||
const streamPath = particlePath(networkId, [streamId]);
|
||||
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
||||
|
||||
if (isLoading && !particle) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<StatusBar style="light" hidden />
|
||||
<ActivityIndicator color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !particle || particle.type !== "stream") {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-center">
|
||||
{error
|
||||
? "Couldn't load this stream."
|
||||
: "This stream is no longer available."}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamView
|
||||
streamParticle={particle}
|
||||
path={streamPath}
|
||||
onExit={() => navigation.goBack()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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<Particle, { type: "text" }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
}
|
||||
|
||||
// Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per
|
||||
// link/attachment, clamped 3–15s). Mobile v1 has no attachments and we
|
||||
// don't extract link previews mid-render, so the formula collapses to
|
||||
// a length-only base.
|
||||
const CHARS_PER_MINUTE = 1000;
|
||||
const MIN_DURATION_S = 3;
|
||||
const MAX_DURATION_S = 15;
|
||||
const TICK_MS = 100;
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function computeReadDuration(text: string): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
|
||||
}
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
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" };
|
||||
}
|
||||
|
||||
export function TextParticleView({
|
||||
particle,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: TextParticleViewProps) {
|
||||
const content = particle.properties.content;
|
||||
const durationS = computeReadDuration(content);
|
||||
const elapsedRef = useRef(0);
|
||||
const safe = useStreamSafeArea();
|
||||
|
||||
// Reset when the particle changes.
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
onProgress(0);
|
||||
}, [particle.id, onProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedRef.current += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||
onProgress(ratio);
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen note.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
||||
const style = getImmersiveStyle(content.length);
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center px-8"
|
||||
style={{
|
||||
paddingTop: safe.top + 16,
|
||||
paddingBottom: safe.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={cn("text-white text-center max-w-xl", style.className)}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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. Padding is
|
||||
// pulled from the StreamSafeArea so the card never slips under chrome.
|
||||
return (
|
||||
<View
|
||||
className="flex-1 items-center justify-center px-6"
|
||||
style={{
|
||||
paddingTop: safe.top + 16,
|
||||
paddingBottom: safe.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||
contentContainerClassName="px-5 py-5"
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
>
|
||||
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useChannel } from "@/hooks/use-channel";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export type ComposingMode = "recording" | "typing" | "screen";
|
||||
|
||||
export interface ComposingUser {
|
||||
humanId: string;
|
||||
mode: ComposingMode;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
interface StreamPresenceContextValue {
|
||||
onlineHumanIds: Set<string>;
|
||||
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<StreamPresenceContextValue | null>(
|
||||
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<ComposingUser[]>([]);
|
||||
const composingMapRef = useRef(new Map<string, ComposingUser>());
|
||||
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<ReturnType<typeof setInterval> | 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<StreamPresenceContextValue>(
|
||||
() => ({
|
||||
onlineHumanIds,
|
||||
composingUsers,
|
||||
startComposing,
|
||||
stopComposing,
|
||||
}),
|
||||
[onlineHumanIds, composingUsers, startComposing, stopComposing],
|
||||
);
|
||||
|
||||
return (
|
||||
<StreamPresenceContext.Provider value={value}>
|
||||
{children}
|
||||
</StreamPresenceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<StreamSafeArea>({ 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 <Ctx.Provider value={{ top, bottom }}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useStreamSafeArea(): StreamSafeArea {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
export const EXIT_DELAY_MS = 5000;
|
||||
export const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
/**
|
||||
* Returns the remaining ms when the stream has ended, or null otherwise.
|
||||
* Pauses while `paused` is true (compose, hold-to-pause, swipe-down…).
|
||||
*/
|
||||
export function useExitCountdown(
|
||||
status: PlaybackStatus,
|
||||
paused: boolean,
|
||||
onExit: () => void,
|
||||
): number | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const handleExit = useEvent(onExit);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || paused) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
if (prev === null) return null;
|
||||
const next = prev - EXIT_TICK_MS;
|
||||
return next <= 0 ? 0 : next;
|
||||
});
|
||||
}, EXIT_TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs !== null && remainingMs <= 0) {
|
||||
handleExit();
|
||||
}
|
||||
}, [remainingMs, handleExit]);
|
||||
|
||||
return remainingMs;
|
||||
}
|
||||
Reference in New Issue
Block a user