- 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).
199 lines
7.4 KiB
TypeScript
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 },
|
|
});
|
|
}
|