diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx
index 5cabd39..e338305 100644
--- a/mobile/app/(app)/_layout.tsx
+++ b/mobile/app/(app)/_layout.tsx
@@ -50,6 +50,7 @@ export default function AppLayout() {
+
makeStyles(colors), [colors]);
+ const { getToken, signOut } = useAuth();
+ const { pets, clearActivePetId } = useActivePet();
+ const queryClient = useQueryClient();
+ const [petName, setPetName] = useState("");
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const isValidName = pets.some((pet) => pet.name.toLowerCase() === petName.trim().toLowerCase());
+
+ const deleteAccount = async () => {
+ setIsDeleting(true);
+ setError(null);
+ try {
+ const token = await getToken();
+ const res = await fetch(`${env.apiUrl}/api/account/delete`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({ petNameConfirmation: petName.trim() }),
+ });
+ if (!res.ok) {
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
+ throw new Error(data.error ?? t("deleteAccount.genericError"));
+ }
+ } catch (err) {
+ Sentry.captureException(err);
+ setError(err instanceof Error ? err.message : t("deleteAccount.genericError"));
+ setIsDeleting(false);
+ return;
+ }
+
+ // The Clerk user no longer exists server-side, so signOut() may reject —
+ // either way the local session is cleared and useProtectedRoute() sends
+ // the user back to sign-in.
+ queryClient.clear();
+ clearActivePetId();
+ Alert.alert(t("deleteAccount.doneTitle"));
+ await signOut().catch(() => undefined);
+ };
+
+ const confirmDelete = () => {
+ Alert.alert(t("deleteAccount.finalConfirmTitle"), t("deleteAccount.finalConfirmMessage"), [
+ { text: t("common.cancel"), style: "cancel" },
+ { text: t("deleteAccount.confirmDelete"), style: "destructive", onPress: () => void deleteAccount() },
+ ]);
+ };
+
+ return (
+
+
+
+
+ {t("deleteAccount.warningTitle")}
+
+ {t("deleteAccount.warningIntro")}
+
+
+ {DELETED_ITEMS.map((item) => (
+
+ ✕
+ {t(`deleteAccount.${item}`)}
+
+ ))}
+
+
+ {t("deleteAccount.confirmIntro")}
+
+ {error ? {error} : null}
+
+
+
+
+ );
+}
+
+function makeStyles(colors: ColorTokens) {
+ const typography: Typography = getTypography(colors);
+ return StyleSheet.create({
+ flex: { flex: 1, backgroundColor: colors.porchWhite },
+ content: { padding: 20, gap: 12, flexGrow: 1, backgroundColor: colors.porchWhite },
+ titleRow: { flexDirection: "row", alignItems: "center", gap: 8 },
+ title: { ...typography.title, fontSize: 18, color: colors.alertRed },
+ intro: { ...typography.body, color: colors.quietGray },
+ card: {
+ gap: 8,
+ padding: 14,
+ borderRadius: radii.xl,
+ borderWidth: 1,
+ borderColor: colors.hairlineGray,
+ backgroundColor: colors.pureWhite,
+ },
+ item: { ...typography.body },
+ itemMark: { color: colors.alertRed, fontWeight: "700" },
+ error: { ...typography.body, color: colors.alertRed },
+ deleteButton: { marginTop: 8, backgroundColor: colors.alertRed },
+ });
+}
diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx
index 778128f..703a6bd 100644
--- a/mobile/app/(app)/pets/[petId]/index.tsx
+++ b/mobile/app/(app)/pets/[petId]/index.tsx
@@ -1,9 +1,9 @@
-import { useMemo } from "react";
-import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
-import { useLocalSearchParams, useRouter } from "expo-router";
+import { useMemo, useState } from "react";
+import { View, Text, FlatList, Pressable, ActivityIndicator, Alert, StyleSheet } from "react-native";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation } from "@tanstack/react-query";
-import { HeartPulse, Trophy, MessageCircle } from "lucide-react-native";
+import { HeartPulse, Trophy, MessageCircle, MoreHorizontal } from "lucide-react-native";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { useTheme } from "../../../../src/context/ThemeContext";
@@ -12,6 +12,8 @@ import { PetIdentityBadges } from "../../../../src/components/PetIdentityBadges"
import { PostCard } from "../../../../src/components/PostCard";
import { Button } from "../../../../src/components/Button";
import { FollowButton } from "../../../../src/components/FollowButton";
+import { ReportSheet } from "../../../../src/components/ReportSheet";
+import { useBlockPet } from "../../../../src/hooks/useBlockPet";
import { useVisiblePostIds } from "../../../../src/hooks/useVisiblePostIds";
import { formatPetName } from "../../../../src/lib/format";
import { getAvatarUrl } from "../../../../src/lib/media-url";
@@ -29,7 +31,7 @@ export default function PetProfileScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
- const { activePetId } = useActivePet();
+ const { activePetId, pets: ownPets } = useActivePet();
const { colors, typography } = useTheme();
const styles = useMemo(() => makeStyles(colors), [colors]);
const { visibleIds, viewabilityConfigCallbackPairs } = useVisiblePostIds();
@@ -40,6 +42,11 @@ export default function PetProfileScreen() {
const { data: postsPage } = useQuery(trpc.posts.byPetId.queryOptions({ petId }));
const isOwnPet = activePetId === petId;
+ // Any of the owner's pets, not just the active one — blocking/reporting
+ // your own other pet makes no sense.
+ const isOwnedByMe = ownPets.some((ownPet) => ownPet.id === petId);
+ const [reportOpen, setReportOpen] = useState(false);
+ const { confirmBlock } = useBlockPet(() => router.back());
const getOrCreateConversation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
@@ -64,99 +71,136 @@ export default function PetProfileScreen() {
}
const posts = postsPage?.posts ?? [];
+ const canModerate = !isOwnedByMe && !!activePetId;
+
+ // Report + block for other pets (App Store Guideline 1.2), matching web's
+ // ProfileActions "..." menu.
+ const openMoreMenu = () => {
+ if (!activePetId) return;
+ Alert.alert(formatPetName(pet), undefined, [
+ { text: t("petProfile.reportPet"), onPress: () => setReportOpen(true) },
+ {
+ text: t("block.menuItem", { petName: pet.name }),
+ style: "destructive",
+ onPress: () => confirmBlock(activePetId, { id: pet.id, name: pet.name }),
+ },
+ { text: t("common.cancel"), style: "cancel" },
+ ]);
+ };
return (
- post.id}
- contentContainerStyle={styles.list}
- renderItem={({ item }) => (
-
+ <>
+ {canModerate && (
+ <>
+ (
+
+
+
+ ),
+ }}
+ />
+ setReportOpen(false)}
+ reporterPetId={activePetId}
+ targetPetId={pet.id}
+ />
+ >
)}
- viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
- ItemSeparatorComponent={() => (
-
-
-
- )}
- ListHeaderComponent={
-
- {pet.avatarKey ? (
-
- router.push({
- pathname: "/image-viewer",
- params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
- })
- }
- >
-
-
- ) : (
-
- )}
-
- {formatPetName(pet)}
-
+ post.id}
+ contentContainerStyle={styles.list}
+ renderItem={({ item }) => (
+
+ )}
+ viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
+ ItemSeparatorComponent={() => (
+
+
- {pet.species.name}
-
-
- router.push(`/pets/${petId}/followers`)}>
- {followers?.items.length ?? 0}
- {t("petProfile.followerStat")}
-
- router.push(`/pets/${petId}/following`)}>
- {following?.items.length ?? 0}
- {t("petProfile.followingStat")}
-
-
-
- {isOwnPet ? (
-