feat(mobile): add repost (Paw-Back) creation

PawBackButton toggles reposts.create/delete with an isReposted-backed
optimistic state, mirroring web's PawBackButton — replaces the static
repost count in PostCard's action row. RepostCard renders a REPOST
post as a read-only "Paw-Back von {name}" attribution wrapper around
the original post's pet header, image, and caption, ported from web's
RepostCard with no action row (matches web: only the original post is
reactable/commentable).
This commit is contained in:
admin committed 2026-09-05 15:43:48 +02:00
1 parent 257319e81e
commit 925d121bb8
3 files changed
+182 -12

No files matched your search

+70
View File
@@ -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<boolean | null>(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 (
<Pressable style={styles.button} onPress={handlePress} disabled={isPending || isLoading} hitSlop={8}>
<Repeat2 size={18} color={reposted ? colors.marigoldOrange : colors.quietGray} />
</Pressable>
);
}
const styles = StyleSheet.create({
button: { flexDirection: "row", alignItems: "center" },
});
+25 -12
View File
@@ -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 <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) } });
@@ -96,10 +112,7 @@ export function PostCard({ post, actingPetId }: Props) {
<MessageCircle size={18} color={colors.quietGray} />
<Text style={styles.actionCount}>{post.commentCount ?? 0}</Text>
</Pressable>
<View style={styles.actionGroup}>
<Repeat2 size={18} color={colors.quietGray} />
<Text style={styles.actionCount}>{post.repostCount ?? 0}</Text>
</View>
<PawBackButton postId={post.id} petId={actingPetId} />
</View>
</View>
);
+87
View File
@@ -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 (
<View style={styles.card}>
<Pressable style={styles.attribution} onPress={() => router.push(`/pets/${post.pet.id}`)}>
<PawPrint size={14} color={colors.marigoldOrange} />
<Text style={styles.attributionText}>Paw-Back von {post.pet.name}</Text>
</Pressable>
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" />
<Text style={styles.name}>{origPet.name}</Text>
</Pressable>
{originalPost.milestone ? (
<View style={[styles.placeholder, { width: cardWidth }]}>
<Trophy size={28} color={colors.marigoldOrange} />
</View>
) : originalPost.type === "VIDEO" ? (
<View style={[styles.placeholder, { width: cardWidth }]}>
<Video size={28} color={colors.pureWhite} />
</View>
) : origImage ? (
<Image
source={{ uri: getMediaUrl(origImage.storageKey) }}
style={{ width: cardWidth, aspectRatio: 1.2, borderRadius: radii.md }}
contentFit="cover"
placeholder={origImage.blurhash ? { blurhash: origImage.blurhash } : undefined}
transition={150}
/>
) : null}
{originalPost.caption ? (
<Text style={styles.caption} numberOfLines={3}>
{originalPost.caption}
</Text>
) : null}
</View>
);
}
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",
},
});