158 lines
4.4 KiB
TypeScript
158 lines
4.4 KiB
TypeScript
import {
|
||
ActivityIndicator,
|
||
FlatList,
|
||
Pressable,
|
||
RefreshControl,
|
||
Text,
|
||
View,
|
||
} from "react-native";
|
||
import { SafeAreaView } from "react-native-safe-area-context";
|
||
import { toast } from "sonner-native";
|
||
import { toUserMessage } from "@/lib/errors";
|
||
import { particlePath } from "@/lib/particle-path";
|
||
import { useNetwork, useNetworks } 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);
|
||
// Re-fetching the network list is the closest stand-in for a hard refresh —
|
||
// Firestore subscriptions are already realtime, so pull-to-refresh mainly
|
||
// reassures the user and re-tries network metadata.
|
||
const { refetch: refetchNetworks, isRefetching: isRefetchingNetworks } =
|
||
useNetworks();
|
||
|
||
const path = particlePath(networkId, []);
|
||
// v1: only show open streams. Closed streams are reachable on desktop —
|
||
// an "Archived" surface is a follow-up (PRD §20).
|
||
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="p-3 gap-2 pb-24"
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={isRefetchingNetworks}
|
||
onRefresh={() => refetchNetworks()}
|
||
/>
|
||
}
|
||
renderItem={({ item }) => (
|
||
<StreamCard
|
||
particle={item}
|
||
networkId={networkId}
|
||
onPress={() =>
|
||
navigation.navigate("StreamView", {
|
||
networkId,
|
||
streamId: item.id,
|
||
})
|
||
}
|
||
/>
|
||
)}
|
||
/>
|
||
)}
|
||
|
||
<ComposeFab
|
||
onPress={() => {
|
||
// Real compose flow lands in step 5.
|
||
toast("New stream — coming soon");
|
||
}}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|