diff --git a/mobile/src/components/PawBackButton.tsx b/mobile/src/components/PawBackButton.tsx new file mode 100644 index 0000000..d9e6f1c --- /dev/null +++ b/mobile/src/components/PawBackButton.tsx @@ -0,0 +1,70 @@ +import { useState, useCallback } from "react"; +import { Pressable, StyleSheet } from "react-native"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Repeat2 } from "lucide-react-native"; +import { useTRPC } from "../api/trpc"; +import { colors } from "../theme/tokens"; + +type Props = { + postId: string; // original post ID + petId: string; // active pet (reposter) +}; + +/** + * Port of web's src/components/feed/PawBackButton.tsx — "repost" here is + * branded as "Paw-Back" in the product. No count is shown (web doesn't show + * one either, only the feed's static repostCount elsewhere would, and this + * button replaces that static count display in mobile's action row). + */ +export function PawBackButton({ postId, petId }: Props) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: serverReposted, isLoading } = useQuery(trpc.reposts.isReposted.queryOptions({ postId, petId })); + + // Optimistic local override; null = use server state + const [localState, setLocalState] = useState(null); + const reposted = localState !== null ? localState : (serverReposted ?? false); + + const invalidate = () => { + queryClient.invalidateQueries(trpc.feed.pathFilter()); + queryClient.invalidateQueries({ queryKey: trpc.reposts.isReposted.queryOptions({ postId, petId }).queryKey }); + }; + + const createMutation = useMutation( + trpc.reposts.create.mutationOptions({ + onMutate: () => setLocalState(true), + onSuccess: () => invalidate(), + onError: () => setLocalState(null), + }) + ); + + const deleteMutation = useMutation( + trpc.reposts.delete.mutationOptions({ + onMutate: () => setLocalState(false), + onSuccess: () => invalidate(), + onError: () => setLocalState(null), + }) + ); + + const isPending = createMutation.isPending || deleteMutation.isPending; + + const handlePress = useCallback(() => { + if (isPending || isLoading) return; + if (reposted) { + deleteMutation.mutate({ originalPostId: postId, reposterPetId: petId }); + } else { + createMutation.mutate({ originalPostId: postId, reposterPetId: petId }); + } + }, [isPending, isLoading, reposted, postId, petId, createMutation, deleteMutation]); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + button: { flexDirection: "row", alignItems: "center" }, +}); diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index 7e580cf..dd250d1 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -1,10 +1,12 @@ import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native"; import { useRouter } from "expo-router"; import { Image } from "expo-image"; -import { MessageCircle, Repeat2, Video } from "lucide-react-native"; +import { MessageCircle, Video } 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 { getMediaUrl } from "../lib/media-url"; import { formatPetName, formatRelativeTime } from "../lib/format"; import { colors, typography, radii } from "../theme/tokens"; @@ -24,7 +26,15 @@ type PostCardImage = { blurhash?: string | null; }; -type PostCardPost = { +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; @@ -33,6 +43,7 @@ type PostCardPost = { commentCount?: number | null; repostCount?: number | null; images: PostCardImage[]; + repost?: { originalPost: PostCardOriginalPost } | null; pet: { id: string; name: string; @@ -50,18 +61,23 @@ type Props = { }; /** - * Port of web's src/components/feed/PostCard.tsx, scoped to what Phase 1 - * needs: pet header, image (carousel for 2+), caption, and the reaction/ - * comment/repost row. Comment and repost are static counts in this pass — - * PostDetailDialog and repost creation are later phases, not built yet. - * Video posts and milestone posts get a minimal placeholder rather than - * full treatment (video playback UI is an explicit fast-follow). + * 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 cardWidth = width - 32; // matches the 16px screen padding used by the feed list + // Reposts ("Paw-Backs") delegate to RepostCard — a read-only wrapper + // around the original post, no action row (matches web). + if (post.type === "REPOST") { + return ; + } + 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) } }); @@ -96,10 +112,7 @@ export function PostCard({ post, actingPetId }: Props) { {post.commentCount ?? 0} - - - {post.repostCount ?? 0} - + ); diff --git a/mobile/src/components/RepostCard.tsx b/mobile/src/components/RepostCard.tsx new file mode 100644 index 0000000..dee8642 --- /dev/null +++ b/mobile/src/components/RepostCard.tsx @@ -0,0 +1,87 @@ +import { View, Text, Pressable, StyleSheet, useWindowDimensions } from "react-native"; +import { useRouter } from "expo-router"; +import { Image } from "expo-image"; +import { PawPrint, Video, Trophy } from "lucide-react-native"; +import { PetAvatar } from "./PetAvatar"; +import { getMediaUrl } from "../lib/media-url"; +import { colors, typography, radii } from "../theme/tokens"; +import type { PostCardPost } from "./PostCard"; + +type Props = { + post: PostCardPost; +}; + +/** + * Port of web's src/components/feed/RepostCard.tsx — the "Paw-Back" (repost) + * attribution wrapper around a read-only rendering of the original post. No + * action row (matches web: reposts aren't reactable/commentable as a + * separate entity, only the original post is). Unlike web's bordered-card + * look, this stays visually flush with mobile's other feed items (hairline + * separators between list rows already provide the boundary — see + * DESIGN.md's "hairline rings not shadows"). + */ +export function RepostCard({ post }: Props) { + const router = useRouter(); + const { width } = useWindowDimensions(); + const cardWidth = width - 32; + const originalPost = post.repost?.originalPost; + + if (!originalPost) return null; + + const origPet = originalPost.pet; + const origImage = originalPost.images[0]; + + return ( + + router.push(`/pets/${post.pet.id}`)}> + + Paw-Back von {post.pet.name} + + + router.push(`/pets/${origPet.id}`)}> + + {origPet.name} + + + {originalPost.milestone ? ( + + + + ) : originalPost.type === "VIDEO" ? ( + + + ) : origImage ? ( + + ) : null} + + {originalPost.caption ? ( + + {originalPost.caption} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { gap: 8 }, + attribution: { flexDirection: "row", alignItems: "center", gap: 6 }, + attributionText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray }, + header: { flexDirection: "row", alignItems: "center", gap: 8 }, + name: { ...typography.body, fontFamily: typography.title.fontFamily }, + caption: { ...typography.body }, + placeholder: { + aspectRatio: 1.2, + borderRadius: radii.md, + backgroundColor: colors.ink, + alignItems: "center", + justifyContent: "center", + }, +});