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:
Arjun Patel
2026-04-29 17:39:11 -07:00
committed by GitHub
parent 3a11a82cd3
commit e3461dd5cd
110 changed files with 14682 additions and 22 deletions
@@ -0,0 +1,193 @@
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuthStore } from "@/stores/auth-store";
type Step = "email" | "code";
export function SignInScreen() {
const [step, setStep] = useState<Step>("email");
const [email, setEmail] = useState("");
return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-1 justify-center px-6">
{step === "email" ? (
<EmailStep
onCodeSent={(submittedEmail) => {
setEmail(submittedEmail);
setStep("code");
}}
/>
) : (
<CodeStep email={email} onBack={() => setStep("email")} />
)}
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
const [email, setEmail] = useState("");
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
const error = useAuthStore((s) => s.error);
const requestCode = useAuthStore((s) => s.requestCode);
const clearError = useAuthStore((s) => s.clearError);
const submit = async () => {
try {
await requestCode(email);
onCodeSent(email);
} catch {
// Error surfaced via the store
}
};
const disabled = isRequestingCode || email.trim().length === 0;
return (
<View className="gap-5">
<View className="gap-1">
<Text className="text-foreground text-3xl font-semibold">Sign in</Text>
<Text className="text-muted-foreground text-base">
Enter your email to receive a sign-in code.
</Text>
</View>
<View className="gap-2">
<Text className="text-foreground text-sm font-medium">Email</Text>
<TextInput
value={email}
onChangeText={(text) => {
setEmail(text);
if (error) clearError();
}}
placeholder="[email protected]"
placeholderTextColor="#878787"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="emailAddress"
autoFocus
returnKeyType="go"
onSubmitEditing={submit}
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base"
/>
</View>
{error ? (
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<Pressable
onPress={submit}
disabled={disabled}
className={`rounded-lg px-4 py-3.5 items-center ${
disabled ? "bg-muted" : "bg-primary"
}`}
>
<Text
className={`text-base font-semibold ${
disabled ? "text-muted-foreground" : "text-primary-foreground"
}`}
>
{isRequestingCode ? "Sending..." : "Continue"}
</Text>
</Pressable>
</View>
);
}
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
const [code, setCode] = useState("");
const isSigningIn = useAuthStore((s) => s.isSigningIn);
const error = useAuthStore((s) => s.error);
const signIn = useAuthStore((s) => s.signIn);
const clearError = useAuthStore((s) => s.clearError);
const submit = async () => {
try {
await signIn(email, code);
} catch {
// Error surfaced via the store; keep the screen visible
}
};
const disabled = isSigningIn || code.trim().length === 0;
return (
<View className="gap-5">
<View className="gap-1">
<Text className="text-foreground text-3xl font-semibold">
Check your email
</Text>
<Text className="text-muted-foreground text-base">
We sent a code to{" "}
<Text className="text-foreground font-medium">{email}</Text>.
</Text>
</View>
<View className="gap-2">
<Text className="text-foreground text-sm font-medium">Code</Text>
<TextInput
value={code}
onChangeText={(text) => {
setCode(text);
if (error) clearError();
}}
placeholder="Enter code"
placeholderTextColor="#878787"
keyboardType="number-pad"
autoFocus
returnKeyType="go"
textContentType="oneTimeCode"
onSubmitEditing={submit}
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base tracking-widest"
/>
</View>
{error ? (
<Text className="text-destructive text-sm">{error}</Text>
) : null}
<View className="gap-2">
<Pressable
onPress={submit}
disabled={disabled}
className={`rounded-lg px-4 py-3.5 items-center ${
disabled ? "bg-muted" : "bg-primary"
}`}
>
<Text
className={`text-base font-semibold ${
disabled ? "text-muted-foreground" : "text-primary-foreground"
}`}
>
{isSigningIn ? "Signing in..." : "Sign in"}
</Text>
</Pressable>
<Pressable
onPress={onBack}
className="rounded-lg px-4 py-3.5 items-center"
>
<Text className="text-muted-foreground text-base font-medium">
Back
</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,125 @@
import { useEffect, useRef } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Mic } from "lucide-react-native";
import {
RecordingPresets,
setAudioModeAsync,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface AudioRecordingOverlayProps {
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
}
export function AudioRecordingOverlay({
onComplete,
onCancel,
}: AudioRecordingOverlayProps) {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const state = useAudioRecorderState(recorder, 250);
const finalizedRef = useRef(false);
useEffect(() => {
let active = true;
(async () => {
try {
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
await recorder.prepareToRecordAsync();
if (!active) return;
recorder.record();
} catch (err) {
logError(err, { scope: "compose.audio.start" });
if (active) onCancel();
}
})();
return () => {
active = false;
if (!finalizedRef.current) {
finalizedRef.current = true;
recorder.stop().catch(() => {});
}
void setAudioModeAsync({
allowsRecording: false,
playsInSilentMode: true,
}).catch((err) => logError(err, { scope: "compose.audio.exit" }));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const elapsedMs = state.durationMillis ?? 0;
useEffect(() => {
if (elapsedMs >= MAX_DURATION_S * 1000 && !finalizedRef.current) {
void finish("commit");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elapsedMs]);
const finish = async (kind: "commit" | "cancel") => {
if (finalizedRef.current) return;
finalizedRef.current = true;
const durationMs = state.durationMillis ?? 0;
try {
await recorder.stop();
} catch (err) {
logError(err, { scope: "compose.audio.stop" });
}
if (kind === "cancel") {
onCancel();
return;
}
const uri = recorder.uri;
if (!uri) {
onCancel();
return;
}
onComplete({ uri, durationMs });
};
const elapsedSec = Math.floor(elapsedMs / 1000);
return (
<View
style={StyleSheet.absoluteFill}
className="bg-black items-center justify-center px-8"
>
<View className="bg-red-500/30 h-32 w-32 items-center justify-center rounded-full">
<View className="bg-red-500/60 h-24 w-24 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} />
</View>
</View>
<Text className="text-white mt-6 text-lg font-semibold">
{state.isRecording ? "Recording" : "Starting…"}
</Text>
<Text className="text-white/60 mt-1 text-sm">
{elapsedSec.toString().padStart(2, "0")}s · max {MAX_DURATION_S}s
</Text>
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable
onPress={() => void finish("cancel")}
accessibilityLabel="Cancel recording"
className="rounded-full bg-white/15 px-6 py-3"
>
<Text className="text-white text-base font-medium">Cancel</Text>
</Pressable>
<Pressable
onPress={() => void finish("commit")}
accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3"
>
<Text className="text-black text-base font-semibold">Stop</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,298 @@
import { useCallback, useEffect, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Mic, Type as TypeIcon, Video as VideoIcon } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import {
useCameraPermissions,
useMicrophonePermissions,
} from "expo-camera";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { useEvent } from "@/hooks/use-event";
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
import { useAuthStore } from "@/stores/auth-store";
import {
createTextParticle,
uploadMediaParticle,
} from "@/lib/upload";
import type { ParticlePath } from "@/lib/particle-path";
import {
useStreamComposingBroadcast,
type ComposingMode,
} from "@/features/stream-view/stream-presence-context";
import { TextComposeModal } from "./TextComposeModal";
import { VideoRecordingOverlay } from "./VideoRecordingOverlay";
import { AudioRecordingOverlay } from "./AudioRecordingOverlay";
import { ReviewSheet } from "./ReviewSheet";
type RecordingMode = "video" | "audio";
type ComposeUiState =
| { kind: "idle" }
| { kind: "recording"; mode: RecordingMode }
| {
kind: "review";
mode: RecordingMode;
uri: string;
durationMs: number;
}
| { kind: "uploading" };
interface SubmitMediaParams {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
}
interface ComposeDockProps {
networkId: string;
targetPath: ParticlePath;
silentPresence?: boolean;
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
submitText?: (content: string) => Promise<void>;
}
export function ComposeDock({
networkId,
targetPath,
silentPresence = false,
submitMedia,
submitText: submitTextOverride,
}: ComposeDockProps) {
const userId = useAuthStore((s) => s.user?.id);
const [mode, setMode] = useState<RecordingMode>("video");
const [ui, setUi] = useState<ComposeUiState>({ kind: "idle" });
const [textOpen, setTextOpen] = useState(false);
const [camPerm, requestCamPerm] = useCameraPermissions();
const [micPerm, requestMicPerm] = useMicrophonePermissions();
// Tell StreamView to fully unmount its expo-video player while we record.
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
const isComposing = ui.kind !== "idle" || textOpen;
useEffect(() => {
setComposing(isComposing);
return () => setComposing(false);
}, [isComposing, setComposing]);
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
const ensurePermissions = useCallback(
async (forVideo: boolean): Promise<boolean> => {
if (forVideo) {
const cam = camPerm?.granted ? camPerm : await requestCamPerm();
if (!cam.granted) {
toast.error("Camera permission is required to record video.");
return false;
}
}
const mic = micPerm?.granted ? micPerm : await requestMicPerm();
if (!mic.granted) {
toast.error("Microphone permission is required to record.");
return false;
}
return true;
},
[camPerm, micPerm, requestCamPerm, requestMicPerm],
);
const startRecording = useEvent(async () => {
if (ui.kind !== "idle") return;
const ok = await ensurePermissions(mode === "video");
if (!ok) return;
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setUi({ kind: "recording", mode });
});
const handleRecordingComplete = useCallback(
({ uri, durationMs }: { uri: string; durationMs: number }) => {
void Haptics.selectionAsync();
setUi((prev) => {
const m = "mode" in prev ? prev.mode : mode;
return { kind: "review", mode: m, uri, durationMs };
});
},
[mode],
);
const handleRecordingCancel = useCallback(() => {
setUi({ kind: "idle" });
}, []);
const sendReview = useEvent(async () => {
if (ui.kind !== "review" || !userId) return;
const captured = ui;
setUi({ kind: "uploading" });
try {
const mimeType =
captured.mode === "audio" ? "audio/mp4" : "video/mp4";
if (submitMedia) {
await submitMedia({
fileUri: captured.uri,
mimeType,
durationMs: captured.durationMs,
source: "camera",
});
} else {
await uploadMediaParticle({
networkId,
targetPath,
fileUri: captured.uri,
mimeType,
durationMs: captured.durationMs,
source: "camera",
createdByHumanId: userId,
});
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setUi({ kind: "idle" });
} catch (err) {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
setUi(captured);
throw err;
}
});
const retake = useCallback(() => setUi({ kind: "idle" }), []);
const cancelReview = useCallback(() => setUi({ kind: "idle" }), []);
const submitText = useEvent(async (content: string) => {
if (!userId) throw new Error("Not signed in.");
if (submitTextOverride) {
await submitTextOverride(content);
} else {
await createTextParticle({
networkId,
targetPath,
content,
createdByHumanId: userId,
});
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
});
const dockHidden =
ui.kind === "review" ||
ui.kind === "uploading" ||
ui.kind === "recording";
return (
<>
{!dockHidden ? (
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
<View
pointerEvents="box-none"
className="flex-row items-center justify-between px-8 pb-10"
>
<Pressable
onPress={() =>
setMode((m) => (m === "video" ? "audio" : "video"))
}
disabled={ui.kind !== "idle"}
accessibilityLabel={`Switch to ${
mode === "video" ? "audio" : "video"
} mode`}
className={cn(
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
ui.kind !== "idle" && "opacity-40",
)}
>
{mode === "video" ? (
<VideoIcon color="white" size={20} strokeWidth={1.6} />
) : (
<Mic color="white" size={20} strokeWidth={1.6} />
)}
</Pressable>
<View className="items-center">
<Pressable
onPress={startRecording}
disabled={ui.kind !== "idle"}
accessibilityLabel={`Record ${mode}`}
className="h-20 w-20 items-center justify-center rounded-full bg-white"
>
<View className="h-6 w-6 rounded bg-black" />
</Pressable>
<Text className="text-white/60 mt-2 text-xs">
Tap to record
</Text>
</View>
<Pressable
onPress={() => setTextOpen(true)}
disabled={ui.kind !== "idle"}
accessibilityLabel="Compose text"
className={cn(
"h-11 w-11 items-center justify-center rounded-full bg-white/15",
ui.kind !== "idle" && "opacity-40",
)}
>
<TypeIcon color="white" size={20} strokeWidth={1.6} />
</Pressable>
</View>
</View>
) : null}
{ui.kind === "recording" ? (
ui.mode === "video" ? (
<VideoRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
) : (
<AudioRecordingOverlay
onComplete={handleRecordingComplete}
onCancel={handleRecordingCancel}
/>
)
) : null}
<ReviewSheet
open={ui.kind === "review"}
uri={ui.kind === "review" ? ui.uri : null}
mode={ui.kind === "review" ? ui.mode : null}
durationMs={ui.kind === "review" ? ui.durationMs : 0}
onSend={sendReview}
onRetake={retake}
onCancel={cancelReview}
/>
<TextComposeModal
open={textOpen}
onClose={() => setTextOpen(false)}
onSubmit={submitText}
/>
</>
);
}
function useComposingBroadcast({
ui,
textOpen,
silent,
}: {
ui: ComposeUiState;
textOpen: boolean;
silent: boolean;
}) {
let broadcast: ReturnType<typeof useStreamComposingBroadcast> | null;
try {
broadcast = useStreamComposingBroadcast();
} catch {
broadcast = null;
}
const mode: ComposingMode | null =
ui.kind === "recording" ? "recording" : textOpen ? "typing" : null;
useEffect(() => {
if (silent || !broadcast) return;
if (mode) {
broadcast.startComposing(mode);
return () => broadcast?.stopComposing();
}
}, [mode, silent, broadcast]);
}
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from "react-native-safe-area-context";
import { useVideoPlayer, VideoView } from "expo-video";
import { Mic } from "lucide-react-native";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
interface ReviewSheetProps {
open: boolean;
/** Local file URI from the recorder. */
uri: string | null;
mode: "video" | "audio" | null;
durationMs: number;
onSend: () => Promise<void>;
onRetake: () => void;
onCancel: () => void;
}
/**
* Loop-plays the just-recorded clip and offers Retake / Send. WhatsApp-style
* confirmation: any send-failure surfaces a toast and keeps the sheet open
* so the user doesn't lose their take.
*/
export function ReviewSheet({
open,
uri,
mode,
durationMs,
onSend,
onRetake,
onCancel,
}: ReviewSheetProps) {
const player = useVideoPlayer(uri ?? "", (p) => {
p.loop = true;
p.muted = false;
p.audioMixingMode = "mixWithOthers";
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open && uri) {
player.play();
}
}, [open, uri, player]);
const handleSend = async () => {
if (submitting) return;
setSubmitting(true);
try {
await onSend();
} catch (err) {
toast.error(toUserMessage(err));
setSubmitting(false);
}
};
const seconds = Math.max(1, Math.round(durationMs / 1000));
return (
<Modal
visible={open}
animationType="fade"
transparent={false}
onRequestClose={onCancel}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View className="flex-1 bg-black">
{uri ? (
mode === "audio" ? (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 h-28 w-28 items-center justify-center rounded-full">
<Mic color="white" size={42} strokeWidth={1.5} />
</View>
<Text className="text-white mt-6 text-lg font-semibold">
Voice message · {seconds}s
</Text>
<Text className="text-white/50 mt-2 text-sm">
Tap send to share, or retake.
</Text>
<View className="absolute" style={{ width: 1, height: 1 }}>
<VideoView
style={{ width: 1, height: 1 }}
player={player}
nativeControls={false}
/>
</View>
</View>
) : (
<VideoView
style={{ flex: 1 }}
player={player}
nativeControls={false}
contentFit="cover"
allowsFullscreen={false}
allowsPictureInPicture={false}
/>
)
) : null}
<SafeAreaView
edges={["top"]}
className="absolute top-0 left-0 right-0"
>
<View className="px-4 pt-3">
<Pressable
onPress={onCancel}
hitSlop={12}
accessibilityLabel="Cancel"
>
<Text className="text-white/80 text-base">Cancel</Text>
</Pressable>
</View>
</SafeAreaView>
<SafeAreaView
edges={["bottom"]}
className="absolute bottom-0 left-0 right-0"
>
<View className="flex-row items-center justify-between px-6 pb-4 pt-3">
<Pressable
onPress={onRetake}
disabled={submitting}
className="rounded-full bg-white/15 px-5 py-3"
accessibilityLabel="Retake"
>
<Text className="text-white text-base font-medium">Retake</Text>
</Pressable>
<Pressable
onPress={handleSend}
disabled={submitting}
className={cn(
"rounded-full px-7 py-3",
submitting ? "bg-white/40" : "bg-white",
)}
accessibilityLabel="Send"
>
<Text className="text-black text-base font-semibold">
{submitting ? "Sending..." : "Send"}
</Text>
</Pressable>
</View>
</SafeAreaView>
</View>
</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from "react";
import {
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { cn } from "@/lib/utils";
import { toUserMessage } from "@/lib/errors";
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveStyle(length: number) {
if (length === 0)
return { className: "text-3xl font-semibold leading-snug" };
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" };
}
interface TextComposeModalProps {
open: boolean;
onClose: () => void;
/**
* Submit handler — must throw if the upload fails so the modal can re-show
* the editor and the user doesn't lose their text.
*/
onSubmit: (content: string) => Promise<void>;
}
/**
* Immersive full-screen text editor. Mirrors desktop's `text-editor.tsx`:
* dynamic font ramp at 30/70/130 chars, no markdown preview, no gradient.
* Long messages scroll inside the multiline TextInput. Pauses upstream
* playback (the host wraps render in a useSuspendPlayback while open).
*/
export function TextComposeModal({
open,
onClose,
onSubmit,
}: TextComposeModalProps) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<TextInput>(null);
// Reset whenever the modal opens fresh.
useEffect(() => {
if (open) {
setContent("");
setSubmitting(false);
// Re-focus on next tick; iOS occasionally drops the autoFocus call when
// the modal animation is mid-flight.
const t = setTimeout(() => inputRef.current?.focus(), 60);
return () => clearTimeout(t);
}
}, [open]);
const trimmed = content.trim();
const canSend = trimmed.length > 0 && !submitting;
const handleSend = async () => {
if (!canSend) return;
setSubmitting(true);
try {
await onSubmit(trimmed);
onClose();
} catch (err) {
toast.error(toUserMessage(err));
setSubmitting(false);
}
};
const style = getImmersiveStyle(trimmed.length);
const isImmersive = trimmed.length < IMMERSIVE_CHAR_LIMIT;
return (
<Modal
visible={open}
animationType="fade"
transparent={false}
onRequestClose={onClose}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<SafeAreaView className="flex-1 bg-black" edges={["top", "bottom"]}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-row items-center justify-between px-4 py-3">
<Pressable
onPress={onClose}
accessibilityLabel="Cancel"
hitSlop={12}
>
<Text className="text-white/70 text-base">Cancel</Text>
</Pressable>
<Pressable
onPress={handleSend}
disabled={!canSend}
hitSlop={12}
accessibilityLabel="Send"
>
<Text
className={cn(
"text-base font-semibold",
canSend ? "text-white" : "text-white/30",
)}
>
{submitting ? "Sending..." : "Send"}
</Text>
</Pressable>
</View>
<View className="flex-1 justify-center px-6 pb-6">
<TextInput
ref={inputRef}
value={content}
onChangeText={setContent}
placeholder="Type a message"
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
autoFocus
autoCorrect
autoCapitalize="sentences"
editable={!submitting}
scrollEnabled={!isImmersive}
textAlignVertical={isImmersive ? "center" : "top"}
style={{
color: "white",
textAlign: isImmersive ? "center" : "left",
maxHeight: isImmersive ? undefined : 540,
}}
className={cn("text-white", style.className)}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaProvider>
</Modal>
);
}
@@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { CameraView } from "expo-camera";
import { logError } from "@/lib/errors";
const MAX_DURATION_S = 60;
interface VideoRecordingOverlayProps {
onComplete: (result: { uri: string; durationMs: number }) => void;
onCancel: () => void;
}
export function VideoRecordingOverlay({
onComplete,
onCancel,
}: VideoRecordingOverlayProps) {
const cameraRef = useRef<CameraView>(null);
const [cameraReady, setCameraReady] = useState(false);
const [recording, setRecording] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
useEffect(() => {
return () => {
cancelledRef.current = true;
};
}, []);
const startRecording = async () => {
const cam = cameraRef.current;
if (!cam || recording || !cameraReady) return;
setRecording(true);
startedAtRef.current = Date.now();
let result: { uri: string } | undefined;
try {
result = await cam.recordAsync({ maxDuration: MAX_DURATION_S });
} catch (err) {
if (cancelledRef.current) return;
logError(err, { scope: "compose.video.recordAsync" });
onCancel();
return;
}
if (cancelledRef.current) return;
const durationMs =
startedAtRef.current !== null ? Date.now() - startedAtRef.current : 0;
if (result?.uri) {
onComplete({ uri: result.uri, durationMs });
} else {
onCancel();
}
};
const stopRecording = () => {
cameraRef.current?.stopRecording();
};
const cancel = () => {
cancelledRef.current = true;
if (recording) {
cameraRef.current?.stopRecording();
}
onCancel();
};
useEffect(() => {
if (!recording) return;
const interval = setInterval(() => {
if (startedAtRef.current === null) return;
setElapsedMs(Date.now() - startedAtRef.current);
}, 250);
return () => clearInterval(interval);
}, [recording]);
const elapsedSec = Math.floor(elapsedMs / 1000);
return (
<View style={StyleSheet.absoluteFill} className="bg-black">
<CameraView
ref={cameraRef}
style={StyleSheet.absoluteFill}
facing="front"
mode="video"
mute={false}
onCameraReady={() => setCameraReady(true)}
/>
{recording ? (
<View
pointerEvents="none"
className="absolute top-0 left-0 right-0 items-center pt-16"
>
<View className="bg-red-500/90 flex-row items-center gap-2 rounded-full px-3 py-1.5">
<View className="h-2 w-2 rounded-full bg-white" />
<Text className="text-white text-xs font-semibold tracking-wide">
REC · {elapsedSec.toString().padStart(2, "0")}s
</Text>
</View>
</View>
) : null}
<View className="absolute inset-x-0 bottom-12 flex-row items-center justify-around px-8">
<Pressable
onPress={cancel}
accessibilityLabel="Cancel"
className="rounded-full bg-white/15 px-6 py-3"
>
<Text className="text-white text-base font-medium">Cancel</Text>
</Pressable>
{recording ? (
<Pressable
onPress={stopRecording}
accessibilityLabel="Stop recording"
className="rounded-full bg-white px-7 py-3"
>
<Text className="text-black text-base font-semibold">Stop</Text>
</Pressable>
) : (
<Pressable
onPress={startRecording}
disabled={!cameraReady}
accessibilityLabel="Start recording"
className={
cameraReady
? "h-20 w-20 items-center justify-center rounded-full bg-white"
: "h-20 w-20 items-center justify-center rounded-full bg-white/40"
}
>
<View className="h-16 w-16 rounded-full bg-red-500" />
</Pressable>
)}
</View>
</View>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useRef } from "react";
import {
Animated,
Dimensions,
Easing,
Modal,
Pressable,
Text,
View,
} from "react-native";
import {
initialWindowMetrics,
SafeAreaProvider,
SafeAreaView,
} from "react-native-safe-area-context";
import { useAuthStore } from "@/stores/auth-store";
const SCREEN_WIDTH = Dimensions.get("window").width;
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
const ANIM_MS = 220;
interface DrawerProps {
open: boolean;
onClose: () => void;
onNavigateAccount: () => void;
onNavigateSettings: () => void;
}
export function Drawer({
open,
onClose,
onNavigateAccount,
}: DrawerProps) {
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(translateX, {
toValue: open ? 0 : -DRAWER_WIDTH,
duration: ANIM_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: open ? 0.4 : 0,
duration: ANIM_MS,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}, [open, translateX, backdropOpacity]);
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
return (
<Modal
visible={open}
transparent
animationType="none"
onRequestClose={onClose}
>
{/* Modal mounts a separate native view tree on iOS — without a fresh
SafeAreaProvider seeded with initialWindowMetrics, useSafeAreaInsets
inside reports {0,0,0,0} on the first frame and content snaps from
the status bar down to the safe area once metrics resolve. */}
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<View className="flex-1">
<Animated.View
pointerEvents={open ? "auto" : "none"}
style={{ opacity: backdropOpacity }}
className="absolute inset-0 bg-black"
>
<Pressable className="flex-1" onPress={onClose} />
</Animated.View>
<Animated.View
style={{
width: DRAWER_WIDTH,
transform: [{ translateX }],
}}
className="absolute left-0 top-0 bottom-0 bg-sidebar"
>
<SafeAreaView edges={["top", "bottom", "left"]} className="flex-1">
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
{initials}
</Text>
</View>
<View className="flex-1">
<Text
className="text-sidebar-foreground text-base font-medium"
numberOfLines={1}
>
{user?.email_prefix ?? ""}
</Text>
<Text
className="text-muted-foreground text-xs"
numberOfLines={1}
>
{user?.email ?? ""}
</Text>
</View>
</View>
<View className="flex-1 py-2">
<DrawerRow
label="Account"
onPress={() => {
onClose();
onNavigateAccount();
}}
/>
</View>
<View className="border-sidebar-border border-t px-2 py-2">
<DrawerRow
label={isSigningOut ? "Signing out..." : "Sign out"}
disabled={isSigningOut}
onPress={() => {
void signOut();
}}
tone="destructive"
/>
</View>
</SafeAreaView>
</Animated.View>
</View>
</SafeAreaProvider>
</Modal>
);
}
function DrawerRow({
label,
onPress,
disabled,
tone = "default",
}: {
label: string;
onPress: () => void;
disabled?: boolean;
tone?: "default" | "destructive";
}) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
className="px-5 py-3 active:bg-sidebar-accent"
>
<Text
className={`text-base font-medium ${
tone === "destructive"
? "text-destructive"
: "text-sidebar-foreground"
} ${disabled ? "opacity-50" : ""}`}
>
{label}
</Text>
</Pressable>
);
}
@@ -0,0 +1,136 @@
import { useCallback, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import type { Network } from "@/api/types";
import { useNetworks } from "@/hooks/use-networks";
import { useAuthStore } from "@/stores/auth-store";
import { toUserMessage } from "@/lib/errors";
import type { RootStackScreenProps } from "@/navigation/types";
import { Drawer } from "./Drawer";
export function NetworkListScreen({
navigation,
}: RootStackScreenProps<"NetworkList">) {
const [drawerOpen, setDrawerOpen] = useState(false);
const { data, isLoading, refetch, error } = useNetworks();
const user = useAuthStore((s) => s.user);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
// Local refreshing state — driving RefreshControl from react-query's
// isRefetching can leave the native spinner visually stuck after the
// screen is detached/reattached by native-stack.
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
await refetch();
} finally {
setRefreshing(false);
}
}, [refetch]);
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Pressable
onPress={() => setDrawerOpen(true)}
accessibilityLabel="Open menu"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
>
<Text className="text-muted-foreground text-xs font-semibold">
{initials}
</Text>
</Pressable>
<Text className="text-foreground text-base font-semibold">Flowy</Text>
<View className="w-9" />
</View>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : error ? (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-destructive text-center">
{toUserMessage(error)}
</Text>
<Pressable onPress={() => refetch()} className="mt-3 px-4 py-2">
<Text className="text-foreground font-medium">Retry</Text>
</Pressable>
</View>
) : !data || data.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
contentContainerClassName="p-4 gap-2"
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({ item }) => (
<NetworkCard
network={item}
onPress={() =>
navigation.navigate("StreamList", { networkId: item.id })
}
/>
)}
/>
)}
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onNavigateAccount={() => navigation.navigate("Account")}
onNavigateSettings={() => navigation.navigate("Settings")}
/>
</SafeAreaView>
);
}
function NetworkCard({
network,
onPress,
}: {
network: Network;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className="bg-card border-border active:bg-accent rounded-lg border px-4 py-4 flex-row items-center justify-between"
>
<View className="flex-1">
<Text className="text-card-foreground text-base font-semibold">
{network.name}
</Text>
<Text className="text-muted-foreground text-sm">
{network.humans.length}{" "}
{network.humans.length === 1 ? "member" : "members"}
</Text>
</View>
<Text className="text-muted-foreground text-xl"></Text>
</Pressable>
);
}
function EmptyState() {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center">
You aren't in any networks yet.
</Text>
<Text className="text-muted-foreground mt-2 text-center">
Ask a friend for an invite, or create one on desktop.
</Text>
</View>
);
}
@@ -0,0 +1,37 @@
import { Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuthStore } from "@/stores/auth-store";
import type { RootStackScreenProps } from "@/navigation/types";
export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
const user = useAuthStore((s) => s.user);
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
Account
</Text>
<View className="w-8" />
</View>
<View className="px-6 py-6 gap-4">
<Field label="Email" value={user?.email ?? "—"} />
</View>
</SafeAreaView>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<View className="gap-1">
<Text className="text-muted-foreground text-xs uppercase tracking-wide">
{label}
</Text>
<Text className="text-foreground text-base">{value}</Text>
</View>
);
}
@@ -0,0 +1,27 @@
import { Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import type { RootStackScreenProps } from "@/navigation/types";
export function SettingsScreen({
navigation,
}: RootStackScreenProps<"Settings">) {
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
Settings
</Text>
<View className="w-8" />
</View>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-muted-foreground text-center">
Theme, notifications, and account preferences land here later.
</Text>
</View>
</SafeAreaView>
);
}
@@ -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;
/** 01 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 315s). 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;
}
@@ -0,0 +1,206 @@
import { useMemo, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { ChevronRight, Globe, Lock, X } from "lucide-react-native";
import { toast } from "sonner-native";
import { ComposeDock } from "@/features/compose/ComposeDock";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { generateRandomName } from "@/lib/random-name";
import { createStreamWithFirstParticle } from "@/lib/upload";
import { toUserMessage } from "@/lib/errors";
import {
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { useAuthStore } from "@/stores/auth-store";
import type { RootStackScreenProps } from "@/navigation/types";
import { VisibilityPickerSheet } from "./VisibilityPickerSheet";
const STREAM_NAME_MAX = 60;
/**
* Top-level stream creation. The user names the stream, picks visibility, and
* composes the first particle on one screen — desktop's compose-overlay flow
* collapsed into a touch-native single page.
*/
export function NewStreamScreen({
route,
navigation,
}: RootStackScreenProps<"NewStream">) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const userId = useAuthStore((s) => s.user?.id);
const suggestion = useMemo(() => generateRandomName(), []);
const [name, setName] = useState("");
const [visibleTo, setVisibleTo] = useState<string[]>(() =>
buildNetworkVisibility(networkId),
);
const [pickerOpen, setPickerOpen] = useState(false);
const effectiveName = name.trim() || suggestion;
const handleStreamCreated = (streamId: string) => {
navigation.replace("StreamView", { networkId, streamId });
};
const submitText = async (content: string) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: { type: "text", content },
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const submitMedia = async ({
fileUri,
mimeType,
durationMs,
source,
}: {
fileUri: string;
mimeType: string;
durationMs: number;
source: "camera" | "screen";
}) => {
if (!userId) throw new Error("Not signed in.");
try {
const { streamId } = await createStreamWithFirstParticle({
networkId,
name: effectiveName,
visibleTo,
createdByHumanId: userId,
firstParticle: {
type: "media",
fileUri,
mimeType,
durationMs,
source,
},
});
handleStreamCreated(streamId);
} catch (err) {
toast.error(toUserMessage(err));
throw err;
}
};
const placeholderPath = particlePath(networkId, []);
const visibility = parseVisibleTo(visibleTo, networkId);
const visibleSummary =
visibility.mode === "network"
? `Everyone in ${network?.name ?? "this network"}`
: `${visibility.humanIds.length} ${
visibility.humanIds.length === 1 ? "person" : "people"
}`;
return (
<View className="flex-1 bg-black">
<StatusBar style="light" />
<SafeAreaView edges={["top"]}>
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Pressable
onPress={() => navigation.goBack()}
hitSlop={12}
accessibilityLabel="Cancel"
>
<X color="white" size={22} strokeWidth={1.8} />
</Pressable>
<Text className="text-white text-base font-semibold">
New stream
</Text>
<View style={{ width: 22 }} />
</View>
</SafeAreaView>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-1 px-6 pt-4">
<Text className="text-white/60 text-xs uppercase tracking-wide mb-2">
Name
</Text>
<TextInput
value={name}
onChangeText={(v) => setName(v.slice(0, STREAM_NAME_MAX))}
placeholder={suggestion}
placeholderTextColor="rgba(255,255,255,0.35)"
autoCapitalize="none"
autoCorrect={false}
maxLength={STREAM_NAME_MAX}
className="bg-white/10 rounded-xl px-4 py-3 text-white text-lg"
/>
<Text className="text-white/60 text-xs uppercase tracking-wide mt-6 mb-2">
Visible to
</Text>
<Pressable
onPress={() => setPickerOpen(true)}
className="bg-white/10 active:bg-white/15 rounded-xl px-4 py-3 flex-row items-center gap-3"
>
{visibility.mode === "network" ? (
<Globe color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
) : (
<Lock color="rgba(255,255,255,0.7)" size={18} strokeWidth={1.6} />
)}
<Text className="text-white text-base flex-1" numberOfLines={1}>
{visibleSummary}
</Text>
<ChevronRight
color="rgba(255,255,255,0.5)"
size={18}
strokeWidth={1.6}
/>
</Pressable>
<View className="mt-6 px-1">
<Text className="text-white/50 text-sm">
Hold the button below to record a voice or video message that's
the first particle in your new stream.
</Text>
</View>
</View>
</KeyboardAvoidingView>
<ComposeDock
networkId={networkId}
targetPath={placeholderPath}
silentPresence
submitMedia={submitMedia}
submitText={submitText}
/>
<VisibilityPickerSheet
open={pickerOpen}
onClose={() => setPickerOpen(false)}
networkId={networkId}
networkName={network?.name}
humans={network?.humans ?? []}
selfHumanId={userId}
visibleTo={visibleTo}
onChange={setVisibleTo}
/>
</View>
);
}
@@ -0,0 +1,156 @@
import { memo, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import type { Particle, StreamProperties } from "@/api/types";
import { isParticleDeleted } from "@/api/types";
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { cn, getInitials } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onPress: () => void;
}
/**
* Mobile counterpart of js/desktop/src/features/particles/stream-card.tsx —
* same data wiring (subscribe to the latest child for unread + initials),
* touch-tuned layout (single row, no preview thumbnail in v1).
*/
export const StreamCard = memo(function StreamCard({
particle,
networkId,
onPress,
}: StreamCardProps) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const previewLabel = useMemo(() => {
if (!latestChild) return "No messages yet";
if (isParticleDeleted(latestChild)) return "Message deleted";
switch (latestChild.type) {
case "media":
return latestChild.properties.mime_type.startsWith("audio/")
? "Voice message"
: "Video message";
case "text":
return latestChild.properties.content;
case "file":
return latestChild.properties.filename;
case "quest":
return latestChild.properties.title;
case "paper":
return latestChild.properties.title;
default:
return "Update";
}
}, [latestChild]);
return (
<Pressable
onPress={onPress}
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
className={cn(
"bg-card border-b px-3.5 py-3 flex-row items-center gap-3 active:bg-accent",
isUnseen ? "border-primary" : "border-border",
)}
>
<View
className={cn(
"h-10 w-10 items-center justify-center rounded-full",
isUnseen ? "bg-primary" : "bg-muted",
)}
>
<Text
className={cn(
"text-xs font-semibold",
isUnseen ? "text-primary-foreground" : "text-muted-foreground",
)}
>
{initials}
</Text>
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className={cn(
"text-base",
isUnseen
? "text-foreground font-semibold"
: "text-foreground font-medium",
)}
>
{particle.properties.name}
</Text>
<Text
numberOfLines={1}
className="text-muted-foreground mt-0.5 text-sm"
>
{previewLabel}
</Text>
</View>
<View className="items-end gap-1">
{latestChild ? (
<RelativeTimestamp
date={latestChild.created_at}
className={cn(
"text-xs",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
/>
) : null}
{isUnseen ? (
<View className="bg-primary h-2 w-2 rounded-full" />
) : null}
</View>
</Pressable>
);
});
@@ -0,0 +1,140 @@
import {
ActivityIndicator,
FlatList,
Pressable,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { toUserMessage } from "@/lib/errors";
import { particlePath } from "@/lib/particle-path";
import { useNetwork } from "@/hooks/use-networks";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import type { RootStackScreenProps } from "@/navigation/types";
import { StreamCard } from "./StreamCard";
export function StreamListScreen({
route,
navigation,
}: RootStackScreenProps<"StreamList">) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const path = particlePath(networkId, []);
const { streams, isLoading, error } = useStreamParticles(path, {
status: "open",
});
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<Header
title={network?.name ?? "Streams"}
onBack={() => navigation.goBack()}
/>
{error ? (
<ErrorState message={toUserMessage(error)} />
) : isLoading && streams.length === 0 ? (
<LoadingState />
) : streams.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={streams}
keyExtractor={(s) => s.id}
contentContainerClassName=""
renderItem={({ item }) => (
<StreamCard
particle={item}
networkId={networkId}
onPress={() =>
navigation.navigate("StreamView", {
networkId,
streamId: item.id,
})
}
/>
)}
/>
)}
<ComposeFab
onPress={() => navigation.navigate("NewStream", { networkId })}
/>
</SafeAreaView>
);
}
function Header({
title,
onBack,
}: {
title: string;
onBack: () => void;
}) {
return (
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable
onPress={onBack}
className="px-2 py-1"
accessibilityLabel="Back"
>
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text
className="flex-1 text-center text-foreground text-base font-semibold"
numberOfLines={1}
>
{title}
</Text>
<View className="w-8" />
</View>
);
}
function LoadingState() {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);
}
function EmptyState() {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center">
No streams yet.
</Text>
<Text className="text-muted-foreground mt-2 text-center">
Tap the button below to start one voice, video, or text.
</Text>
</View>
);
}
function ErrorState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-destructive text-center">{message}</Text>
<Text className="text-muted-foreground mt-2 text-center text-xs">
Streams reconnect automatically once the network is back.
</Text>
</View>
);
}
function ComposeFab({ onPress }: { onPress: () => void }) {
return (
<View className="absolute bottom-6 right-6">
<Pressable
onPress={onPress}
className="bg-primary h-14 w-14 items-center justify-center rounded-full active:opacity-80"
accessibilityLabel="New stream"
>
<Text className="text-primary-foreground text-3xl leading-none">+</Text>
</Pressable>
</View>
);
}
@@ -0,0 +1,218 @@
import { useEffect, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { Check, Globe, Lock, X } from "lucide-react-native";
import type { Human } from "@/api/types";
import { cn } from "@/lib/utils";
import { resolveHumanDisplay } from "@/lib/humans";
import {
buildCustomVisibility,
buildNetworkVisibility,
parseVisibleTo,
} from "@/lib/stream-visibility";
import { BottomSheet } from "@/components/BottomSheet";
import { Avatar } from "@/components/Avatar";
interface VisibilityPickerSheetProps {
open: boolean;
onClose: () => void;
networkId: string;
networkName: string | undefined;
humans: Human[];
selfHumanId: string | undefined;
visibleTo: string[];
onChange: (visibleTo: string[]) => void;
}
/**
* Touch-native visibility picker. Mirrors desktop's stream-members-overlay
* (network-wide vs. specific people) but as a bottom sheet that commits on
* close — the parent's `visibleTo` only updates when the user taps Done.
*/
export function VisibilityPickerSheet({
open,
onClose,
networkId,
networkName,
humans,
selfHumanId,
visibleTo,
onChange,
}: VisibilityPickerSheetProps) {
const initial = useMemo(
() => parseVisibleTo(visibleTo, networkId),
[visibleTo, networkId],
);
const [mode, setMode] = useState<"network" | "custom">(initial.mode);
const [selected, setSelected] = useState<Set<string>>(
() => new Set(initial.mode === "custom" ? initial.humanIds : []),
);
useEffect(() => {
if (!open) return;
setMode(initial.mode);
setSelected(
new Set(initial.mode === "custom" ? initial.humanIds : []),
);
}, [open, initial]);
const others = humans.filter((h) => h.id !== selfHumanId);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const commit = () => {
if (mode === "network") {
onChange(buildNetworkVisibility(networkId));
} else {
const ids = selfHumanId
? [selfHumanId, ...Array.from(selected)]
: Array.from(selected);
onChange(buildCustomVisibility(ids));
}
onClose();
};
const customCount = selected.size + (selfHumanId ? 1 : 0);
const canCommit = mode === "network" || customCount >= 2;
return (
<BottomSheet open={open} onClose={onClose} maxHeight="80%">
<View className="flex-row items-center justify-between px-5 pb-3">
<Pressable onPress={onClose} hitSlop={12}>
<X color="rgba(255,255,255,0.7)" size={22} />
</Pressable>
<Text className="text-white text-base font-semibold">Visible to</Text>
<Pressable onPress={commit} disabled={!canCommit} hitSlop={12}>
<Text
className={cn(
"text-base font-semibold",
canCommit ? "text-white" : "text-white/30",
)}
>
Done
</Text>
</Pressable>
</View>
<View className="px-5 pb-3">
<View className="flex-row gap-1 bg-white/5 rounded-lg p-1">
<ModePill
active={mode === "network"}
icon={<Globe color="white" size={14} />}
label="Everyone"
onPress={() => setMode("network")}
/>
<ModePill
active={mode === "custom"}
icon={<Lock color="white" size={14} />}
label="Specific people"
onPress={() => setMode("custom")}
/>
</View>
</View>
{mode === "network" ? (
<View className="px-5 pb-6">
<Text className="text-white/60 text-sm">
Everyone in {networkName ?? "this network"} can see this stream.
</Text>
</View>
) : (
<ScrollView contentContainerClassName="px-2 pb-4">
{others.length === 0 ? (
<Text className="text-white/50 text-sm px-3 py-4">
You're the only member of this network. Invite people on desktop,
then come back to choose specific viewers.
</Text>
) : (
others.map((human) => {
const display = resolveHumanDisplay(human.id, humans);
const isSelected = selected.has(human.id);
return (
<Pressable
key={human.id}
onPress={() => toggle(human.id)}
className={cn(
"flex-row items-center gap-3 px-3 py-2.5 rounded-lg",
isSelected ? "bg-white/10" : "active:bg-white/5",
)}
>
<Avatar
humanId={human.id}
humans={humans}
size="sm"
/>
<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>
<View
className={cn(
"h-6 w-6 items-center justify-center rounded-full border",
isSelected
? "bg-white border-white"
: "border-white/30",
)}
>
{isSelected ? (
<Check color="black" size={14} strokeWidth={3} />
) : null}
</View>
</Pressable>
);
})
)}
</ScrollView>
)}
</BottomSheet>
);
}
function ModePill({
active,
icon,
label,
onPress,
}: {
active: boolean;
icon: React.ReactNode;
label: string;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-1 flex-row items-center justify-center gap-1.5 rounded-md py-2",
active ? "bg-white/15" : "",
)}
>
{icon}
<Text
className={cn(
"text-xs",
active ? "text-white font-semibold" : "text-white/60",
)}
>
{label}
</Text>
</Pressable>
);
}