fix(mobile): notification separators/navigation, own-post delete option

Notifications: add a visible hairline separator between rows and make
each row tappable — it marks read and navigates to the relevant post
(comments screen) or pet profile, porting web's getHref()/getPostId()
routing logic.

Feed: the "..." menu on a post now opens a delete confirmation instead
of the report sheet when the post belongs to the currently active pet;
report sheet stays unchanged for posts from other pets. Reuses the
pre-existing posts.delete mutation, no backend changes needed.
This commit is contained in:
admin committed 2026-09-06 19:39:52 +02:00
1 parent 6b01046ded
commit 73ea97ac16
5 files changed
+92 -15

No files matched your search

+41 -5
View File
@@ -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={() => <View style={styles.separator} />}
ItemSeparatorComponent={() => <View style={styles.separatorLine} />}
onEndReachedThreshold={0.5}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
@@ -111,7 +139,11 @@ export default function NotificationsScreen() {
renderItem={({ item }) => (
<Pressable
style={[styles.row, !item.read && styles.rowUnread]}
onPress={() => !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 ? (
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
@@ -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 },
+7
View File
@@ -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.
-
+36 -8
View File
@@ -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 (
<View style={styles.card}>
<View style={styles.headerRow}>
@@ -114,16 +140,18 @@ export function PostCard({ post, actingPetId, isTrending }: Props) {
<Text style={styles.timestamp}>{formatRelativeTime(post.createdAt)}</Text>
</View>
</Pressable>
<Pressable style={styles.moreButton} onPress={() => setReportOpen(true)} hitSlop={8}>
<Pressable style={styles.moreButton} onPress={handleMorePress} hitSlop={8}>
<MoreVertical size={18} color={colors.quietGray} />
</Pressable>
</View>
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={actingPetId}
targetPostId={post.id}
/>
{!isOwnPost && (
<ReportSheet
visible={reportOpen}
onClose={() => setReportOpen(false)}
reporterPetId={actingPetId}
targetPostId={post.id}
/>
)}
{post.type === "VIDEO" && post.videoPost ? (
<VideoPlayer
+4 -1
View File
@@ -76,7 +76,10 @@
"maxImages": "Maximal {{max}} Fotos pro Beitrag.",
"thumbError": "Fehler",
"captionPlaceholder": "Schreibe eine Bildunterschrift…",
"shareButton": "Teilen"
"shareButton": "Teilen",
"deleteConfirmTitle": "Beitrag wirklich löschen?",
"deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.",
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut."
},
"story": {
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
+4 -1
View File
@@ -76,7 +76,10 @@
"maxImages": "Maximum {{max}} photos per post.",
"thumbError": "Error",
"captionPlaceholder": "Write a caption…",
"shareButton": "Share"
"shareButton": "Share",
"deleteConfirmTitle": "Delete this post?",
"deleteConfirmMessage": "This post will be permanently deleted.",
"deleteError": "Couldn't delete this post. Please try again."
},
"story": {
"changePhotoHint": "Tap to choose a different photo",