step 2.5: streams list

This commit is contained in:
talksik
2026-04-29 11:24:29 -07:00
parent cd1837c5f1
commit 73cc65c5d1
14 changed files with 1816 additions and 28 deletions
@@ -1,34 +1,157 @@
import { Pressable, Text, View } from "react-native";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNetwork } from "@/hooks/use-networks";
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 network = useNetwork(route.params.networkId);
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"]}>
<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"
numberOfLines={1}
>
{network?.name ?? "Streams"}
</Text>
<View className="w-8" />
</View>
<Header
title={network?.name ?? "Streams"}
onBack={() => navigation.goBack()}
/>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-muted-foreground text-center">
Stream list coming in step 3.
</Text>
</View>
{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>
);
}