- 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>
323 lines
12 KiB
TypeScript
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 },
|
|
});
|
|
}
|