diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 74ddefc..d0eb218 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo } from "react"; import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; -import { useFocusEffect } from "expo-router"; +import { useFocusEffect, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; @@ -37,10 +37,38 @@ function getNotificationText(item: NotificationItem, t: TFunction): string { return item.type; } -/** Port of web's notifications list — mark-read on tap, no deep-linking to the target post/comment yet. */ +/** + * Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to + * the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page + * (the anniversary is about *this* pet, there's no other actor to visit), + * and REACTION/COMMENT/MENTION/PAW_BACK go to the post. Web opens the post + * in an inline dialog; mobile has no such dialog, so this goes to the + * comments screen instead — the closest existing route to "the post". + * MESSAGE has no target here either, matching web (it doesn't handle it + * there so this doesn't try to be smarter about it). + */ +function getNotificationRoute(item: NotificationItem): string | null { + switch (item.type) { + case "FOLLOW": + return item.actorPet ? `/pets/${item.actorPet.id}` : null; + case "BIRTHDAY": + case "ADOPTION_DAY": + return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null; + case "REACTION": + case "COMMENT": + case "MENTION": + case "PAW_BACK": + return item.post?.id ? `/posts/${item.post.id}/comments` : null; + default: + return null; + } +} + +/** Port of web's notifications list — tapping a row marks it read and navigates to its target (post/pet). */ export default function NotificationsScreen() { const { t } = useTranslation(); const trpc = useTRPC(); + const router = useRouter(); const queryClient = useQueryClient(); const { activePetId } = useActivePet(); const { colors } = useTheme(); @@ -102,7 +130,7 @@ export default function NotificationsScreen() { data={notifications} keyExtractor={(item) => item.id} contentContainerStyle={styles.list} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => } onEndReachedThreshold={0.5} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); @@ -111,7 +139,11 @@ export default function NotificationsScreen() { renderItem={({ item }) => ( !item.read && markRead.mutate({ notificationId: item.id })} + onPress={() => { + if (!item.read) markRead.mutate({ notificationId: item.id }); + const route = getNotificationRoute(item); + if (route) router.push(route); + }} > {item.actorPet ? ( @@ -139,7 +171,11 @@ function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ list: { padding: 16, flexGrow: 1, backgroundColor: colors.porchWhite }, - separator: { height: 8 }, + // A visible hairline plus vertical breathing room — found.md: the old + // 8px gap alone didn't read as separate rows, especially between two + // unread (tinted-background) notifications sitting right on top of + // each other. + separatorLine: { height: 1, backgroundColor: colors.hairlineGray, marginVertical: 6 }, center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, errorText: { ...typography.body, color: colors.alertRed }, bodyText: { ...typography.body }, diff --git a/mobile/found.md b/mobile/found.md index f076382..2afa69d 100644 --- a/mobile/found.md +++ b/mobile/found.md @@ -30,3 +30,10 @@ TODO Neu2: → Links + "Version 1.0.0" unterhalb der Sprachauswahl ergänzt, live getestet (Impressum öffnet im Browser). - [x] Symbole für Deutsch Englisch und Automatisch im Meine tiere menü ändern. auf flaggen z.b. → 🇩🇪 / 🇬🇧 / 🌐 statt Icon, live bestätigt. +- [x] Benachrichtigungen trennen zwischen den einzelnen Benachrichtigungen für mehr optik + → Sichtbare Trennlinie zwischen den Einträgen ergänzt, live bestätigt. +- [x] Benachrichtigungen sind nicht anklickbar, sollten aber zu dem jeweiligen Post / Pet gehen. + → Tap markiert als gelesen und navigiert zum Ziel (Post-Kommentare bzw. Tierprofil je nach Typ), live bestätigt. +- [x] Drei Punkte, menü im Feed, falls es der eigene Beitrag ist, lösch funktion hinzufügen, statt Report. + → Bei eigenem Beitrag öffnet "⋮" jetzt einen Lösch-Bestätigungsdialog statt der Report-Ansicht, bei fremden Beiträgen bleibt Report wie gewohnt; beides live bestätigt. +- \ No newline at end of file diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index 0473997..87a5b70 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -1,9 +1,11 @@ import { useMemo, useState } from "react"; -import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native"; +import { View, Text, Pressable, Alert, StyleSheet, ScrollView, useWindowDimensions } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; import { MessageCircle, MoreVertical, Flame } from "lucide-react-native"; +import { useTRPC } from "../api/trpc"; import { PetAvatar } from "./PetAvatar"; import { PetIdentityBadges } from "./PetIdentityBadges"; import { PawButton } from "./PawButton"; @@ -79,10 +81,23 @@ export function PostCard({ post, actingPetId, isTrending }: Props) { const { t } = useTranslation(); const { width } = useWindowDimensions(); const router = useRouter(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); 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 deleteMutation = useMutation( + trpc.posts.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries(trpc.feed.pathFilter()); + queryClient.invalidateQueries(trpc.posts.pathFilter()); + }, + onError: () => Alert.alert(t("post.deleteError")), + }) + ); // Reposts ("Paw-Backs") delegate to RepostCard — a read-only wrapper // around the original post, no action row (matches web). @@ -95,6 +110,17 @@ export function PostCard({ post, actingPetId, isTrending }: Props) { router.push({ pathname: "/image-viewer", params: { images: JSON.stringify(uris), index: String(index) } }); }; + const handleMorePress = () => { + if (isOwnPost) { + Alert.alert(t("post.deleteConfirmTitle"), t("post.deleteConfirmMessage"), [ + { text: t("common.cancel"), style: "cancel" }, + { text: t("common.delete"), style: "destructive", onPress: () => deleteMutation.mutate({ postId: post.id }) }, + ]); + } else { + setReportOpen(true); + } + }; + return ( @@ -114,16 +140,18 @@ export function PostCard({ post, actingPetId, isTrending }: Props) { {formatRelativeTime(post.createdAt)} - setReportOpen(true)} hitSlop={8}> + - setReportOpen(false)} - reporterPetId={actingPetId} - targetPostId={post.id} - /> + {!isOwnPost && ( + setReportOpen(false)} + reporterPetId={actingPetId} + targetPostId={post.id} + /> + )} {post.type === "VIDEO" && post.videoPost ? (