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:
1 parent
257319e81e
commit
925d121bb8
3 files changed
+182
-12
No files matched your search
@@ -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" },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user