Files
llink/js/mobile/src/features/streams/StreamCard.tsx
T
2026-04-29 11:24:29 -07:00

157 lines
4.6 KiB
TypeScript

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>
);
});