step 2.5: streams list
This commit is contained in:
@@ -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 rounded-xl border 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user