Files
petfeed/mobile/src/components/PostCard.tsx
T
adminandClaude Opus 5.5 9f34f0e5c3 refactor(mobile): Audit O1 – doppelte Helfer zusammengelegt (M1–M3, M5–M7)
- M1: typography.caption statt 44× label ohne Großschreibung; sectionTitleStyle.ts entfällt
- M2: uploadPickedImage() für verkleinern → presign → PUT (Post, Meilenstein, Story, Avatar)
- M3: uploadImageToSupabase in uploadFileToUrl aufgegangen
- M5: usePawToggle für PawButton + StoryPawButton (Beitrags-Pfote zählt jetzt sofort mit)
- M6: healthSectionStyles für Impfung, Tierarzt, Gewicht
- M7: unbenutzten Typ PostDetail entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:18:44 +02:00

323 lines
12 KiB
TypeScript

import { useMemo, useState } from "react";
import { View, Text, Pressable, Alert, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { MessageCircle, MoreVertical, Flame } from "lucide-react-native";
import { useTRPC } from "../api/trpc";
import { PetAvatar } from "./PetAvatar";
import { PetIdentityBadges } from "./PetIdentityBadges";
import { PawButton } from "./PawButton";
import { PawBackButton } from "./PawBackButton";
import { RepostCard } from "./RepostCard";
import { VideoPlayer } from "./VideoPlayer";
import { ReportSheet } from "./ReportSheet";
import { useBlockPet } from "../hooks/useBlockPet";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { useTheme } from "../context/ThemeContext";
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
import type { ReferralOwnerFields } from "../hooks/useReferralRanks";
/**
* Structural rather than imported from a single router's inferred output:
* `feed.getFeed` selects `pet` via `petCardSelect` (isVerified/owner
* present), but `posts.byPetId` selects it via the narrower
* `petIdentitySelect` (those keys absent, not just undefined) — both need
* to satisfy this prop type, so isVerified/owner are optional here.
*/
type PostCardImage = {
id: string;
storageKey: string;
width?: number | null;
height?: number | null;
blurhash?: string | null;
};
type PostCardOriginalPost = {
caption?: string | null;
type?: string | null;
images: PostCardImage[];
milestone?: unknown;
pet: { id: string; name: string; avatarKey?: string | null; owner?: ReferralOwnerFields | null };
};
export type PostCardPost = {
id: string;
type?: string | null;
caption?: string | null;
createdAt: string | Date;
reactionCount?: number | null;
commentCount?: number | null;
repostCount?: number | null;
images: PostCardImage[];
repost?: { originalPost: PostCardOriginalPost } | null;
videoPost?: { status: "PROCESSING" | "READY" | "ERROR"; muxPlaybackId?: string | null } | null;
pet: {
id: string;
name: string;
nickname?: string | null;
avatarKey?: string | null;
isVerified?: boolean;
deceasedAt?: Date | string | null;
lostSince?: Date | string | null;
isFoundingMember?: boolean;
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
// identity's owner (posts.byPetId, comments) carries just the referral rank.
owner?: {
sponsorSince?: Date | string | null;
sponsorPeriodEnd?: Date | string | null;
referralRankId?: string | null;
referralPerks?: string[] | null;
} | null;
};
};
type Props = {
post: PostCardPost;
/** The signed-in owner's currently active pet — the one reacting/acting. */
actingPetId: string;
/** Set when this post was mixed into the feed from explore.getTrending rather than being a real followed-pet post — see FeedScreen's interleaving logic. */
isTrending?: boolean;
/** False once scrolled off-screen — pauses video playback instead of looping forever unseen (found.md). Defaults true for single-post screens without viewability tracking. */
isVisible?: boolean;
};
/**
* Port of web's src/components/feed/PostCard.tsx: pet header, image
* (carousel for 2+), caption, and the reaction/comment/paw-back row. Video
* posts get a minimal placeholder rather than full treatment (video
* playback UI is an explicit fast-follow). Repost ("Paw-Back") posts
* delegate entirely to RepostCard before any of this renders.
*/
export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Props) {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { colors } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const cardWidth = width - 32; // matches the 16px screen padding used by the feed list
const [reportOpen, setReportOpen] = useState(false);
const isOwnPost = post.pet.id === actingPetId;
const { confirmBlock } = useBlockPet();
const deleteMutation = useMutation(
trpc.posts.delete.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(trpc.feed.pathFilter());
queryClient.invalidateQueries(trpc.posts.pathFilter());
},
onError: () => Alert.alert(t("post.deleteError")),
})
);
// Reposts ("Paw-Backs") delegate to RepostCard — a read-only wrapper
// around the original post, no action row (matches web).
if (post.type === "REPOST") {
return <RepostCard post={post} />;
}
const openViewer = (index: number) => {
const uris = post.images.map((image) => getMediaUrl(image.storageKey));
router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(uris), index: String(index) } });
};
const handleMorePress = () => {
if (isOwnPost) {
Alert.alert(t("post.deleteConfirmTitle"), t("post.deleteConfirmMessage"), [
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) },
]);
} else {
// Report + block (App Store Guideline 1.2), matching web PostCard's
// "..." menu for other pets' posts.
Alert.alert(formatPetName(post.pet), undefined, [
{ text: t("report.title"), onPress: () => setReportOpen(true) },
{
text: t("block.menuItem", { petName: post.pet.name }),
style: "destructive",
onPress: () => confirmBlock(actingPetId, { id: post.pet.id, name: post.pet.name }),
},
{ text: t("common.cancel"), style: "cancel" },
]);
}
};
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} owner={post.pet.owner} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isLost={!!post.pet.lostSince} isDeceased={!!post.pet.deceasedAt} isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
{isTrending && (
<View style={styles.trendingBadge}>
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
<Text style={styles.trendingBadgeText}>{t("feed.trendingBadge")}</Text>
</View>
)}
</View>
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
</Pressable>
<Pressable style={styles.moreButton} onPress={handleMorePress} hitSlop={8}>
<MoreVertical size={18} color={colors.quietGray} />
</Pressable>
</View>
{!isOwnPost && (
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={actingPetId}
targetPostId={post.id}
/>
)}
{post.type === "VIDEO" && post.videoPost ? (
<VideoPlayer
postId={post.id}
initialStatus={post.videoPost.status}
initialPlaybackId={post.videoPost.muxPlaybackId}
width={cardWidth}
isVisible={isVisible}
/>
) : (
<PostImages images={post.images} width={cardWidth} onPressImage={openViewer} />
)}
{post.caption ? <Text style={styles.caption}>{post.caption}</Text> : null}
<View style={styles.actions}>
<PawButton postId={post.id} petId={actingPetId} initialCount={post.reactionCount ?? 0} />
<Pressable style={styles.actionGroup} onPress={() => router.push(`/posts/${post.id}/comments`)}>
<MessageCircle size={18} color={colors.quietGray} />
<Text style={styles.actionCount}>{post.commentCount ?? 0}</Text>
</Pressable>
<PawBackButton postId={post.id} petId={actingPetId} />
</View>
</View>
);
}
type PostImagesProps = {
images: PostCardImage[];
width: number;
onPressImage: (index: number) => void;
};
/**
* found.md: a multi-image post gave no visual hint it was swipeable — a
* viewer would only ever see the first photo unless they already knew to
* swipe. Ports web's PostCard indicators (index badge top-right + dots
* below) so the carousel is discoverable at a glance.
*/
function PostImages({ images, width, onPressImage }: PostImagesProps) {
const { colors } = useTheme();
const [index, setIndex] = useState(0);
if (images.length === 0) return null;
if (images.length === 1) {
return <PostImage image={images[0]} width={width} onPress={() => onPressImage(0)} />;
}
return (
<View>
<View style={{ width }}>
<ScrollView
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onMomentumScrollEnd={(e) => setIndex(Math.round(e.nativeEvent.contentOffset.x / width))}
>
{images.map((image, i) => (
<PostImage key={image.id} image={image} width={width} onPress={() => onPressImage(i)} />
))}
</ScrollView>
<View style={multiImageStyles.counterBadge}>
<Text style={multiImageStyles.counterText}>
{index + 1} / {images.length}
</Text>
</View>
</View>
<View style={multiImageStyles.dotsRow}>
{images.map((_, i) => (
<View
key={i}
style={[
multiImageStyles.dot,
{ backgroundColor: i === index ? colors.ink : colors.hairlineGray },
]}
/>
))}
</View>
</View>
);
}
const multiImageStyles = StyleSheet.create({
counterBadge: {
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: radii.sm,
paddingHorizontal: 6,
paddingVertical: 2,
},
counterText: { color: "#fff", fontSize: 11 },
dotsRow: { flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 6, paddingVertical: 8 },
dot: { width: 6, height: 6, borderRadius: 3 },
});
function PostImage({ image, width, onPress }: { image: PostCardImage; width: number; onPress: () => void }) {
const rawRatio = image.width && image.height ? image.width / image.height : 1;
const aspectRatio = Math.min(1.91, Math.max(0.75, rawRatio));
return (
<Pressable onPress={onPress}>
<Image
source={{ uri: getMediaUrl(image.storageKey) }}
style={{ width, aspectRatio, borderRadius: radii.md }}
contentFit="cover"
placeholder={image.blurhash ? { blurhash: image.blurhash } : undefined}
transition={150}
/>
</Pressable>
);
}
function makeStyles(colors: ColorTokens) {
const typography: Typography = getTypography(colors);
return StyleSheet.create({
card: { gap: 10 },
headerRow: { flexDirection: "row", alignItems: "center" },
header: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
moreButton: { padding: 4 },
headerText: { flex: 1 },
nameRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap" },
name: { ...typography.body, fontFamily: typography.title.fontFamily },
trendingBadge: {
flexDirection: "row",
alignItems: "center",
gap: 3,
marginLeft: 8,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: radii.sm,
backgroundColor: colors.marigoldTint,
},
trendingBadgeText: { ...typography.caption, fontSize: 10, color: colors.marigoldDeep },
timestamp: { ...typography.caption },
caption: { ...typography.body },
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
actionCount: { ...typography.body, color: colors.quietGray, fontSize: 13 },
});
}