Files
petfeed/mobile/src/components/PostCard.tsx
T
admin 4a35fce20b fix(mobile): found.md batch — reports, dark mode, health form fixes
- new report feature: a "..." button on every post opens a reason-picker
  sheet (reports.create), matching web's ReportSheet reasons/copy
- pet profile, edit, followers/following, and the whole health screen
  (incl. consent gate) converted to the makeStyles(colors) dark-mode
  pattern
- fixed the invisible back button on every pushed (app)/_layout.tsx
  screen: headerTintColor was never set, so the native-stack default
  back chevron kept its platform tint instead of the theme color —
  black-on-black in dark mode looked like a missing button entirely
- health forms (weight/vet visit/vaccine) now take dates as DD-MM-YYYY
  instead of the backend's raw YYYY-MM-DD, converting internally
  (src/lib/date-format.ts); invalid dates now show an error instead of
  the mutation silently failing with no onError handler at all — this,
  not persistence, was the actual "Gesundheitsdaten werden nicht
  gespeichert" bug
- pinch-to-zoom on the full-screen image viewer via a plain
  PanResponder (no new native dependency)
- Pets tab: language switcher now uses flag emoji (🇩🇪/🇬🇧/🌐), and a
  new footer section links Impressum/Datenschutz/Nutzungsbedingungen
  plus the installed app version

All live-verified against the deployed backend except pinch-zoom
(two-finger gestures aren't simulatable over adb).
2026-09-06 18:55:12 +02:00

199 lines
7.4 KiB
TypeScript

import { useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
import { useRouter } from "expo-router";
import { Image } from "expo-image";
import { MessageCircle, MoreVertical } from "lucide-react-native";
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 { 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";
/**
* 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 };
};
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;
owner?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
};
};
type Props = {
post: PostCardPost;
/** The signed-in owner's currently active pet — the one reacting/acting. */
actingPetId: string;
};
/**
* 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 }: Props) {
const { width } = useWindowDimensions();
const router = useRouter();
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);
// 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) } });
};
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} />
<View style={styles.headerText}>
<View style={styles.nameRow}>
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} />
</View>
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
</Pressable>
<Pressable style={styles.moreButton} onPress={() => setReportOpen(true)} hitSlop={8}>
<MoreVertical size={18} color={colors.quietGray} />
</Pressable>
</View>
<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}
/>
) : (
<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;
};
function PostImages({ images, width, onPressImage }: PostImagesProps) {
if (images.length === 0) return null;
if (images.length === 1) {
return <PostImage image={images[0]} width={width} onPress={() => onPressImage(0)} />;
}
return (
<ScrollView horizontal pagingEnabled showsHorizontalScrollIndicator={false} style={{ width }}>
{images.map((image, index) => (
<PostImage key={image.id} image={image} width={width} onPress={() => onPressImage(index)} />
))}
</ScrollView>
);
}
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" },
name: { ...typography.body, fontFamily: typography.title.fontFamily },
timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 },
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 },
});
}