* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
import { memo, useMemo } from 'react';
|
|
import { Pressable, Text, View } from 'react-native';
|
|
import { Headphones } from 'lucide-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': {
|
|
const mime = latestChild.properties.mime_type;
|
|
if (mime.startsWith('image/')) return 'Photo';
|
|
const transcriptText = latestChild.properties.transcript?.transcript;
|
|
if (transcriptText) return transcriptText;
|
|
return mime.startsWith('audio/') ? 'Voice note' : 'Video clip';
|
|
}
|
|
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="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
|
>
|
|
<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}
|
|
{(particle.huddle_active_participants?.length ?? 0) > 0 ? (
|
|
<View className="flex-row items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
|
<Headphones color="#ef4444" size={11} />
|
|
<Text className="text-red-500 text-[10px] font-semibold">
|
|
{particle.huddle_active_participants?.length}
|
|
</Text>
|
|
</View>
|
|
) : isUnseen ? (
|
|
<View className="bg-primary h-2 w-2 rounded-full" />
|
|
) : null}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
});
|