diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index d0eb218..37ec830 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -29,11 +29,15 @@ const SELF_MESSAGE_KEYS: Partial> = { ADOPTION_DAY: "notifications.adoptionDay", }; -function getNotificationText(item: NotificationItem, t: TFunction): string { +// Suffix only — the actor's name is rendered separately as its own tappable +// Text run (see the row renderer) so it can link to the pet's profile +// independently of the row's own tap target, ported from web's +// notifications/page.tsx (name as a , rest as plain text). +function getNotificationSuffix(item: NotificationItem, t: TFunction): string { const selfKey = SELF_MESSAGE_KEYS[item.type]; if (selfKey) return t(selfKey); const actorKey = ACTOR_MESSAGE_KEYS[item.type]; - if (actorKey && item.actorPet) return t(actorKey, { actor: formatPetName(item.actorPet) }); + if (actorKey && item.actorPet) return t(actorKey); return item.type; } @@ -153,7 +157,14 @@ export default function NotificationsScreen() { )} - {getNotificationText(item, t)} + + {item.actorPet && ( + router.push(`/pets/${item.actorPet!.id}`)}> + {formatPetName(item.actorPet)}{" "} + + )} + {getNotificationSuffix(item, t)} + {formatRelativeTime(item.createdAt)} @@ -179,6 +190,7 @@ function makeStyles(colors: ColorTokens) { center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, errorText: { ...typography.body, color: colors.alertRed }, bodyText: { ...typography.body }, + actorName: { ...typography.body, fontFamily: typography.title.fontFamily }, row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 }, rowUnread: { backgroundColor: colors.marigoldTint }, rowText: { flex: 1, gap: 2 }, diff --git a/mobile/app/(app)/posts/[postId]/comments.tsx b/mobile/app/(app)/posts/[postId]/comments.tsx index 3d1ccd4..311ae3f 100644 --- a/mobile/app/(app)/posts/[postId]/comments.tsx +++ b/mobile/app/(app)/posts/[postId]/comments.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { View, Text, FlatList, ActivityIndicator, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; @@ -6,18 +6,20 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react-native"; import { useTRPC } from "../../../../src/api/trpc"; import { useActivePet } from "../../../../src/context/ActivePetContext"; +import { useTheme } from "../../../../src/context/ThemeContext"; import { PetAvatar } from "../../../../src/components/PetAvatar"; +import { PostCard } from "../../../../src/components/PostCard"; import { TextField } from "../../../../src/components/TextField"; import { Button } from "../../../../src/components/Button"; import { formatPetName, formatRelativeTime } from "../../../../src/lib/format"; -import { colors, typography } from "../../../../src/theme/tokens"; +import { getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens"; import type { PostComment } from "../../../../src/api/types"; /** - * Read + write comments for a post, ported from web's PostDetailDialog - * comments panel (src/components/feed/PostDetailDialog.tsx) but scoped to - * just that: this is a dedicated screen, not a shared image+comments dialog - * (the image already has its own full-view route, image-viewer.tsx). + * Post + comments, ported from web's PostDetailDialog + * (src/components/feed/PostDetailDialog.tsx): notification taps and other + * deep links land here, so the post itself (image/caption/header) is shown + * via the shared PostCard, not just a bare comment list. */ export default function CommentsScreen() { const { t } = useTranslation(); @@ -26,11 +28,14 @@ export default function CommentsScreen() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { activePetId } = useActivePet(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); const listRef = useRef>(null); const [body, setBody] = useState(""); const [pendingBody, setPendingBody] = useState(null); + const { data: post } = useQuery(trpc.posts.getById.queryOptions({ postId })); const { data, isLoading } = useQuery(trpc.comments.listByPost.queryOptions({ postId, limit: 50 })); const comments = data?.comments ?? []; @@ -79,9 +84,16 @@ export default function CommentsScreen() { data={comments} keyExtractor={(item) => item.id} contentContainerStyle={styles.list} + ListHeaderComponent={ + post && activePetId ? ( + + + + ) : null + } ListEmptyComponent={ - {t("comments.empty")} + {t("comments.empty")} } renderItem={({ item }) => ( @@ -98,7 +110,7 @@ export default function CommentsScreen() { {t("comments.sending")} - {pendingBody} + {pendingBody} ) : null @@ -135,6 +147,8 @@ type CommentRowProps = { }; function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) { + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); return ( @@ -147,7 +161,7 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) { {formatRelativeTime(comment.createdAt)} - {comment.body} + {comment.body} {isOwn && ( @@ -158,27 +172,32 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) { ); } -const styles = StyleSheet.create({ - flex: { flex: 1, backgroundColor: colors.porchWhite }, - center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, - list: { padding: 16, flexGrow: 1, gap: 14 }, - row: { flexDirection: "row", gap: 10 }, - pending: { opacity: 0.6, marginTop: 14 }, - rowBody: { flex: 1, gap: 2 }, - rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 }, - petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, - pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, - deleteButton: { alignSelf: "flex-start", padding: 4 }, - inputRow: { - flexDirection: "row", - alignItems: "center", - gap: 8, - padding: 12, - borderTopWidth: 1, - borderTopColor: colors.hairlineGray, - backgroundColor: colors.pureWhite, - }, - input: { flex: 1 }, - sendButton: { width: 88 }, -}); +function makeStyles(colors: ColorTokens) { + const typography: Typography = getTypography(colors); + return StyleSheet.create({ + flex: { flex: 1, backgroundColor: colors.porchWhite }, + center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, + list: { padding: 16, flexGrow: 1, gap: 14 }, + postHeader: { marginBottom: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, + row: { flexDirection: "row", gap: 10 }, + pending: { opacity: 0.6, marginTop: 14 }, + rowBody: { flex: 1, gap: 2 }, + rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 }, + bodyText: { ...typography.body }, + petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 }, + timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, + pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, + deleteButton: { alignSelf: "flex-start", padding: 4 }, + inputRow: { + flexDirection: "row", + alignItems: "center", + gap: 8, + padding: 12, + borderTopWidth: 1, + borderTopColor: colors.hairlineGray, + backgroundColor: colors.pureWhite, + }, + input: { flex: 1 }, + sendButton: { width: 88 }, + }); +} diff --git a/mobile/src/api/types.ts b/mobile/src/api/types.ts index 0056304..fc62bfa 100644 --- a/mobile/src/api/types.ts +++ b/mobile/src/api/types.ts @@ -12,3 +12,4 @@ export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][nu export type ChatMessage = RouterOutputs["messages"]["list"]["messages"][number]; export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number]; export type TrendingPost = RouterOutputs["explore"]["getTrending"]["posts"][number]; +export type PostDetail = RouterOutputs["posts"]["getById"]; diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 2cc6962..c14491a 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -177,12 +177,12 @@ "noFollowing": "Folgt noch niemandem." }, "notifications": { - "follow": "{{actor}} folgt dir jetzt.", - "reaction": "{{actor}} hat deinen Beitrag mit einer Pfote versehen.", - "comment": "{{actor}} hat deinen Beitrag kommentiert.", - "message": "{{actor}} hat dir eine Nachricht geschickt.", - "mention": "{{actor}} hat dich erwähnt.", - "pawBack": "{{actor}} hat zurückgepfotet.", + "follow": "folgt dir jetzt.", + "reaction": "hat deinen Beitrag mit einer Pfote versehen.", + "comment": "hat deinen Beitrag kommentiert.", + "message": "hat dir eine Nachricht geschickt.", + "mention": "hat dich erwähnt.", + "pawBack": "hat zurückgepfotet.", "birthday": "🎂 Alles Gute zum Geburtstag!", "adoptionDay": "🏠 Heute ist der Adoptionstag!", "empty": "Keine Benachrichtigungen." diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index ec41db5..2c58bb7 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -177,12 +177,12 @@ "noFollowing": "Not following anyone yet." }, "notifications": { - "follow": "{{actor}} is now following you.", - "reaction": "{{actor}} pawed your post.", - "comment": "{{actor}} commented on your post.", - "message": "{{actor}} sent you a message.", - "mention": "{{actor}} mentioned you.", - "pawBack": "{{actor}} paw-backed your post.", + "follow": "is now following you.", + "reaction": "pawed your post.", + "comment": "commented on your post.", + "message": "sent you a message.", + "mention": "mentioned you.", + "pawBack": "paw-backed your post.", "birthday": "🎂 Happy birthday!", "adoptionDay": "🏠 Today is adoption day!", "empty": "No notifications."