feat(mobile): block pets and delete account in-app (App Store requirements)
- Block (Guideline 1.2, UGC): useBlockPet hook (native confirm Alert, blocks.create). PostCard "..." on other pets' posts now offers Report / Block; pet profile gets a header "..." with Report pet / Block (hidden for any of the owner's own pets), navigating back after a block. Feed, comments and DMs already enforce blocks server-side; explore and search don't yet (server follow-up). - Delete account (Guideline 5.1.1(v)): new delete-account screen reached from a quiet link below Log out in Settings. Mirrors web's DeleteAccountDialog (warning list + type a pet name) and calls the same POST /api/account/delete with the Clerk bearer token, then clears the query cache and signs out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c3ab5c2a2
commit
4213d7029e
9 files changed
+442
-93
No files matched your search
@@ -50,6 +50,7 @@ export default function AppLayout() {
|
||||
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
||||
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
||||
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
|
||||
<Stack.Screen name="delete-account" options={{ ...headerOptions, title: t("screens.deleteAccount") }} />
|
||||
<Stack.Screen
|
||||
name="post-new"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, ScrollView, Alert, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@clerk/expo";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { TriangleAlert } from "lucide-react-native";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { TextField } from "../../src/components/TextField";
|
||||
import { env } from "../../src/lib/env";
|
||||
import { getTypography, radii, type ColorTokens, type Typography } from "../../src/theme/tokens";
|
||||
|
||||
const DELETED_ITEMS = ["itemPets", "itemPosts", "itemMedia", "itemMessages", "itemHealth", "itemAccount"] as const;
|
||||
|
||||
/**
|
||||
* In-app account deletion — required by App Store Guideline 5.1.1(v) for any
|
||||
* app that lets users create an account. Port of web's
|
||||
* src/components/account/DeleteAccountDialog.tsx: same warning list, same
|
||||
* "type one of your pets' names" confirmation, and the same backend route
|
||||
* (POST /api/account/delete → deleteOwnerAccount(), which also deletes the
|
||||
* Clerk user). That route reads Clerk's auth() like tRPC does, so the mobile
|
||||
* session token in the Authorization header authenticates it.
|
||||
*/
|
||||
export default function DeleteAccountScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => 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<string | null>(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 (
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : undefined}>
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.titleRow}>
|
||||
<TriangleAlert color={colors.alertRed} size={22} />
|
||||
<Text style={styles.title}>{t("deleteAccount.warningTitle")}</Text>
|
||||
</View>
|
||||
<Text style={styles.intro}>{t("deleteAccount.warningIntro")}</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
{DELETED_ITEMS.map((item) => (
|
||||
<Text key={item} style={styles.item}>
|
||||
<Text style={styles.itemMark}>✕ </Text>
|
||||
{t(`deleteAccount.${item}`)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.intro}>{t("deleteAccount.confirmIntro")}</Text>
|
||||
<TextField
|
||||
placeholder={t("deleteAccount.inputPlaceholder")}
|
||||
value={petName}
|
||||
onChangeText={setPetName}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
editable={!isDeleting}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={isDeleting ? t("deleteAccount.deleting") : t("deleteAccount.confirmDelete")}
|
||||
disabled={!isValidName || isDeleting}
|
||||
onPress={confirmDelete}
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
|
||||
<>
|
||||
{canModerate && (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () => (
|
||||
<Pressable onPress={openMoreMenu} hitSlop={12} accessibilityLabel={t("petProfile.moreActions")}>
|
||||
<MoreHorizontal color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<ReportSheet
|
||||
visible={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
reporterPetId={activePetId}
|
||||
targetPetId={pet.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
{pet.avatarKey ? (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/image-viewer",
|
||||
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
)}
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
|
||||
<FlatList
|
||||
data={posts}
|
||||
keyExtractor={(post) => post.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard post={item} actingPetId={activePetId ?? petId} isVisible={visibleIds.has(item.id)} />
|
||||
)}
|
||||
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View style={styles.postSeparatorWrap}>
|
||||
<View style={styles.postSeparatorLine} />
|
||||
</View>
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
|
||||
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
|
||||
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwnPet ? (
|
||||
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
|
||||
) : (
|
||||
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
|
||||
)}
|
||||
|
||||
<View style={styles.iconRow}>
|
||||
{isOwnPet && (
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
|
||||
<HeartPulse color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
|
||||
<Trophy color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
|
||||
</Pressable>
|
||||
{!isOwnPet && activePetId && (
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
{pet.avatarKey ? (
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/image-viewer",
|
||||
params: { images: JSON.stringify([getAvatarUrl(pet.avatarKey)]), index: "0" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<MessageCircle color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
|
||||
{pet.adoptionStory ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
|
||||
<Text style={styles.section}>{pet.adoptionStory}</Text>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} />
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
<View style={styles.statsRow}>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/followers`)}>
|
||||
<Text style={styles.statCount}>{followers?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followerStat")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.stat} onPress={() => router.push(`/pets/${petId}/following`)}>
|
||||
<Text style={styles.statCount}>{following?.items.length ?? 0}</Text>
|
||||
<Text style={styles.statLabel}>{t("petProfile.followingStat")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isOwnPet ? (
|
||||
<Button title={t("common.edit")} variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
|
||||
) : (
|
||||
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
|
||||
)}
|
||||
|
||||
<View style={styles.iconRow}>
|
||||
{isOwnPet && (
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/health`)}>
|
||||
<HeartPulse color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.healthButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.iconButton} onPress={() => router.push(`/pets/${petId}/milestones`)}>
|
||||
<Trophy color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.milestonesButton")}</Text>
|
||||
</Pressable>
|
||||
{!isOwnPet && activePetId && (
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={() => getOrCreateConversation.mutate({ senderPetId: activePetId, recipientPetId: petId })}
|
||||
>
|
||||
<MessageCircle color={colors.ink} size={18} />
|
||||
<Text style={styles.iconButtonText}>{t("petProfile.messageButton")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
|
||||
{pet.adoptionStory ? (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("petProfile.adoptionStoryTitle")}</Text>
|
||||
<Text style={styles.section}>{pet.adoptionStory}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.center}>
|
||||
<Text style={typography.body}>{t("petProfile.noPosts")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LanguageSection } from "../../src/components/settings/LanguageSection";
|
||||
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
|
||||
import { LegalSection } from "../../src/components/settings/LegalSection";
|
||||
import { LogoutSection } from "../../src/components/settings/LogoutSection";
|
||||
import { DeleteAccountSection } from "../../src/components/settings/DeleteAccountSection";
|
||||
import { FooterErrorBoundary } from "../../src/components/settings/FooterErrorBoundary";
|
||||
import type { ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
@@ -43,6 +44,7 @@ export default function SettingsScreen() {
|
||||
<PetSuggestionsSection />
|
||||
<LegalSection />
|
||||
<LogoutSection />
|
||||
<DeleteAccountSection />
|
||||
</ScrollView>
|
||||
</FooterErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -90,6 +91,7 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
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({
|
||||
@@ -119,7 +121,17 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
{ text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) },
|
||||
]);
|
||||
} else {
|
||||
setReportOpen(true);
|
||||
// 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" },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text, StyleSheet } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Entry point to app/(app)/delete-account.tsx. Deliberately a quiet text
|
||||
* link below Log out (like web's DeleteAccountDialog trigger) rather than a
|
||||
* prominent button — it has to be findable (App Store Guideline 5.1.1(v)),
|
||||
* not inviting.
|
||||
*/
|
||||
export function DeleteAccountSection() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/delete-account")} hitSlop={8} style={styles.row}>
|
||||
<Text style={styles.text}>{t("deleteAccount.trigger")}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
row: { alignItems: "center", paddingVertical: 4 },
|
||||
text: { ...typography.body, fontSize: 13, color: colors.alertRed },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
|
||||
type BlockTarget = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Port of web's src/components/safety/BlockDialog.tsx (SAFE-01) as a native
|
||||
* confirm Alert instead of a dialog component. Required for App Store
|
||||
* Guideline 1.2 (UGC apps must let users block abusive accounts).
|
||||
*
|
||||
* The block itself is enforced server-side: feed.getFeed filters blocked
|
||||
* pets, comments/DMs reject blocked pairs. Explore/search don't filter blocks
|
||||
* yet (server change) — invalidating them here is harmless groundwork.
|
||||
*
|
||||
* `onBlocked` lets a caller leave a screen that shows the blocked pet (the
|
||||
* pet profile navigates back).
|
||||
*/
|
||||
export function useBlockPet(onBlocked?: () => void) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const blockMutation = useMutation(
|
||||
trpc.blocks.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(trpc.feed.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.explore.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.search.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.posts.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
onBlocked?.();
|
||||
},
|
||||
onError: () => Alert.alert(t("block.errorToast")),
|
||||
})
|
||||
);
|
||||
|
||||
const confirmBlock = (blockerPetId: string, target: BlockTarget) => {
|
||||
Alert.alert(t("block.title", { petName: target.name }), t("block.description", { petName: target.name }), [
|
||||
{ text: t("block.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("block.confirm"),
|
||||
style: "destructive",
|
||||
onPress: () =>
|
||||
blockMutation.mutate(
|
||||
{ blockerPetId, blockedPetId: target.id },
|
||||
{ onSuccess: () => Alert.alert(t("block.successToast", { petName: target.name })) }
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return { confirmBlock, isBlocking: blockMutation.isPending };
|
||||
}
|
||||
@@ -179,7 +179,9 @@
|
||||
"messageButton": "Nachricht",
|
||||
"adoptionStoryTitle": "Adoptionsgeschichte",
|
||||
"noFollowers": "Noch keine Follower.",
|
||||
"noFollowing": "Folgt noch niemandem."
|
||||
"noFollowing": "Folgt noch niemandem.",
|
||||
"reportPet": "Tier melden",
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"notifications": {
|
||||
"follow": "folgt dir jetzt.",
|
||||
@@ -309,6 +311,35 @@
|
||||
"settings": "Einstellungen",
|
||||
"newPost": "Neuer Beitrag",
|
||||
"newStory": "Neue Story",
|
||||
"newMilestone": "Neuer Meilenstein"
|
||||
"newMilestone": "Neuer Meilenstein",
|
||||
"deleteAccount": "Konto löschen"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "{{petName}} blockieren",
|
||||
"title": "{{petName}} blockieren?",
|
||||
"description": "Posts von {{petName}} werden in deinem Feed ausgeblendet, und ihr könnt euch nicht mehr kommentieren oder schreiben. Niemand erfährt, dass du blockiert hast.",
|
||||
"cancel": "Nicht blockieren",
|
||||
"confirm": "Blockieren",
|
||||
"successToast": "Du hast {{petName}} blockiert.",
|
||||
"errorToast": "Tier konnte nicht blockiert werden. Bitte versuch es erneut."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"trigger": "Konto löschen",
|
||||
"warningTitle": "Konto wirklich löschen?",
|
||||
"warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
|
||||
"itemPets": "Alle Tierprofile",
|
||||
"itemPosts": "Alle Posts, Kommentare und Reaktionen",
|
||||
"itemMedia": "Alle Bilder und Videos",
|
||||
"itemMessages": "Alle Chats und Nachrichten",
|
||||
"itemHealth": "Alle Gesundheitsdaten",
|
||||
"itemAccount": "Dein PawFeed-Konto",
|
||||
"confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
|
||||
"inputPlaceholder": "Tiername eingeben…",
|
||||
"confirmDelete": "Konto endgültig löschen",
|
||||
"finalConfirmTitle": "Letzte Bestätigung",
|
||||
"finalConfirmMessage": "Dein Konto und alle Daten werden sofort und endgültig gelöscht. Das kann nicht rückgängig gemacht werden.",
|
||||
"deleting": "Konto wird gelöscht…",
|
||||
"doneTitle": "Dein Konto wurde gelöscht.",
|
||||
"genericError": "Etwas ist schiefgelaufen. Bitte versuch es erneut."
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,9 @@
|
||||
"messageButton": "Message",
|
||||
"adoptionStoryTitle": "Adoption story",
|
||||
"noFollowers": "No followers yet.",
|
||||
"noFollowing": "Not following anyone yet."
|
||||
"noFollowing": "Not following anyone yet.",
|
||||
"reportPet": "Report pet",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"notifications": {
|
||||
"follow": "is now following you.",
|
||||
@@ -309,6 +311,35 @@
|
||||
"settings": "Settings",
|
||||
"newPost": "New post",
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone"
|
||||
"newMilestone": "New milestone",
|
||||
"deleteAccount": "Delete account"
|
||||
},
|
||||
"block": {
|
||||
"menuItem": "Block {{petName}}",
|
||||
"title": "Block {{petName}}?",
|
||||
"description": "{{petName}}'s posts will be hidden from your feed, and you won't be able to comment on or message each other. They won't know you blocked them.",
|
||||
"cancel": "Don't block",
|
||||
"confirm": "Block",
|
||||
"successToast": "You've blocked {{petName}}.",
|
||||
"errorToast": "Couldn't block this pet. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"trigger": "Delete account",
|
||||
"warningTitle": "Really delete your account?",
|
||||
"warningIntro": "This can't be undone. The following will be permanently deleted:",
|
||||
"itemPets": "All pet profiles",
|
||||
"itemPosts": "All posts, comments and reactions",
|
||||
"itemMedia": "All photos and videos",
|
||||
"itemMessages": "All chats and messages",
|
||||
"itemHealth": "All health records",
|
||||
"itemAccount": "Your PawFeed account",
|
||||
"confirmIntro": "Type the name of one of your pets to confirm the deletion:",
|
||||
"inputPlaceholder": "Enter pet name…",
|
||||
"confirmDelete": "Permanently delete account",
|
||||
"finalConfirmTitle": "Final confirmation",
|
||||
"finalConfirmMessage": "Your account and all data will be deleted immediately and permanently. This can't be undone.",
|
||||
"deleting": "Deleting account…",
|
||||
"doneTitle": "Your account has been deleted.",
|
||||
"genericError": "Something went wrong. Please try again."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user