diff --git a/mobile/app.json b/mobile/app.json
index 20a878e..8edc4cf 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -31,7 +31,14 @@
"expo-font",
"expo-dev-client",
"expo-image",
- "expo-system-ui"
+ "expo-system-ui",
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "Erlaube PawFeed den Zugriff auf deine Fotos, um sie in einem Beitrag zu teilen.",
+ "cameraPermission": "Erlaube PawFeed den Zugriff auf die Kamera, um ein Foto für deinen Beitrag aufzunehmen."
+ }
+ ]
]
}
}
diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx
index 38429dd..a6410a6 100644
--- a/mobile/app/(app)/(tabs)/_layout.tsx
+++ b/mobile/app/(app)/(tabs)/_layout.tsx
@@ -1,7 +1,7 @@
import { View, Text, Pressable, StyleSheet } from "react-native";
import { Tabs, useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
-import { Home, Compass, Bell, Heart, MessageCircle, PawPrint } from "lucide-react-native";
+import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { colors, fontFamily } from "../../../src/theme/tokens";
@@ -31,6 +31,12 @@ export default function TabsLayout() {
name="index"
options={{
headerTitle: () => ,
+ headerRight: () => (
+
+
+
+
+ ),
tabBarLabel: "Feed",
tabBarIcon: ({ color, size }) => ,
}}
@@ -61,6 +67,15 @@ function Wordmark() {
);
}
+function NewPostHeaderButton() {
+ const router = useRouter();
+ return (
+ router.push("/post-new")} hitSlop={12}>
+
+
+ );
+}
+
function MessagesHeaderButton() {
const router = useRouter();
return (
@@ -92,6 +107,7 @@ function NotificationsTabIcon({ color, size }: { color: string; size: number })
}
const styles = StyleSheet.create({
+ headerButtons: { flexDirection: "row", alignItems: "center", gap: 16 },
headerButton: { marginRight: 16 },
wordmark: { flexDirection: "row", alignItems: "center", gap: 8 },
wordmarkText: { fontFamily: fontFamily.bold, fontSize: 20, color: colors.ink },
diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx
index 1ba7ed2..aca14e7 100644
--- a/mobile/app/(app)/_layout.tsx
+++ b/mobile/app/(app)/_layout.tsx
@@ -1,4 +1,6 @@
-import { Stack } from "expo-router";
+import { Pressable } from "react-native";
+import { Stack, useRouter } from "expo-router";
+import { X } from "lucide-react-native";
import { colors, fontFamily } from "../../src/theme/tokens";
const headerOptions = {
@@ -7,6 +9,15 @@ const headerOptions = {
headerTitleStyle: { fontFamily: fontFamily.bold },
} as const;
+function CloseModalButton() {
+ const router = useRouter();
+ return (
+ router.back()} hitSlop={12} style={{ marginLeft: 8 }}>
+
+
+ );
+}
+
export default function AppLayout() {
return (
@@ -16,6 +27,16 @@ export default function AppLayout() {
+
+ ,
+ }}
+ />
([]);
+ const [caption, setCaption] = useState("");
+ const [error, setError] = useState(null);
+
+ const getPresignedUrlMutation = useMutation(trpc.posts.getPresignedUrl.mutationOptions());
+
+ const createMutation = useMutation(
+ trpc.posts.create.mutationOptions({
+ onSuccess: async () => {
+ await queryClient.invalidateQueries(trpc.feed.pathFilter());
+ router.back();
+ },
+ onError: (err) => setError(err.message),
+ })
+ );
+
+ const uploadAsset = useCallback(
+ async (asset: ImagePicker.ImagePickerAsset, index: number) => {
+ const mimeType = asset.mimeType ?? "image/jpeg";
+ try {
+ const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
+ petId: activePetId!,
+ contentType: mimeType as "image/jpeg" | "image/png" | "image/webp",
+ fileSize: asset.fileSize ?? 0,
+ });
+ await uploadImageToSupabase(asset.uri, presignedUrl, mimeType);
+ setImages((prev) => {
+ const updated = [...prev];
+ updated[index] = { ...updated[index], status: "done", key };
+ return updated;
+ });
+ } catch {
+ setImages((prev) => {
+ const updated = [...prev];
+ updated[index] = { ...updated[index], status: "error" };
+ return updated;
+ });
+ }
+ },
+ [activePetId, getPresignedUrlMutation]
+ );
+
+ const addAssets = useCallback(
+ (assets: ImagePicker.ImagePickerAsset[]) => {
+ const available = MAX_IMAGES - images.length;
+ if (available <= 0) {
+ Alert.alert("Maximal 10 Fotos pro Beitrag.");
+ return;
+ }
+
+ const accepted = assets
+ .filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
+ .slice(0, available);
+
+ if (accepted.length === 0) return;
+
+ const startIndex = images.length;
+ const newImages: PickedImage[] = accepted.map((asset) => ({
+ uri: asset.uri,
+ mimeType: asset.mimeType ?? "image/jpeg",
+ key: null,
+ status: "uploading",
+ }));
+ setImages((prev) => [...prev, ...newImages]);
+ accepted.forEach((asset, i) => uploadAsset(asset, startIndex + i));
+ },
+ [images.length, uploadAsset]
+ );
+
+ const pickFromGallery = useCallback(async () => {
+ const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (!permission.granted) {
+ Alert.alert("Zugriff auf Fotos wurde nicht erlaubt.");
+ return;
+ }
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ["images"],
+ allowsMultipleSelection: true,
+ selectionLimit: MAX_IMAGES - images.length,
+ quality: 0.8,
+ });
+ if (!result.canceled) addAssets(result.assets);
+ }, [images.length, addAssets]);
+
+ const takePhoto = useCallback(async () => {
+ const permission = await ImagePicker.requestCameraPermissionsAsync();
+ if (!permission.granted) {
+ Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt.");
+ return;
+ }
+ const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
+ if (!result.canceled) addAssets(result.assets);
+ }, [addAssets]);
+
+ const removeImage = useCallback((index: number) => {
+ setImages((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const doneImages = images.filter((img) => img.status === "done" && img.key);
+ const isUploading = images.some((img) => img.status === "uploading");
+ const canSubmit = doneImages.length > 0 && !isUploading && !createMutation.isPending;
+
+ const handleSubmit = () => {
+ if (!activePetId || doneImages.length === 0) return;
+ setError(null);
+ createMutation.mutate({
+ petId: activePetId,
+ caption: caption.trim() || null,
+ images: doneImages.map((img) => ({ key: img.key as string, blurhash: null })),
+ });
+ };
+
+ return (
+
+ {images.length > 0 && (
+
+ {images.map((img, i) => (
+
+
+ {img.status === "uploading" && (
+
+
+
+ )}
+ {img.status === "error" && (
+
+ Fehler
+
+ )}
+ {img.status !== "uploading" && (
+ removeImage(i)} hitSlop={8}>
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {images.length < MAX_IMAGES && (
+
+
+
+ Galerie
+
+
+
+ Kamera
+
+
+ )}
+
+
+
+ {error ? {error} : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { padding: 16, gap: 12, backgroundColor: colors.porchWhite, flexGrow: 1 },
+ grid: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
+ thumbWrap: { width: 88, height: 88, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray },
+ thumb: { width: "100%", height: "100%" },
+ thumbOverlay: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(0,0,0,0.4)",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ thumbError: { backgroundColor: "rgba(220,38,38,0.6)" },
+ thumbErrorText: { color: colors.pureWhite, fontSize: 11, fontFamily: typography.label.fontFamily },
+ removeButton: {
+ position: "absolute",
+ top: 4,
+ right: 4,
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ backgroundColor: "rgba(0,0,0,0.6)",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ pickerRow: { flexDirection: "row", gap: 12 },
+ pickerButton: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ height: 48,
+ borderRadius: radii.lg,
+ borderWidth: 1,
+ borderColor: colors.hairlineGray,
+ },
+ pickerText: { ...typography.body, color: colors.ink },
+ caption: { height: 88, textAlignVertical: "top", paddingTop: 12 },
+ error: { ...typography.body, color: colors.alertRed },
+});
diff --git a/mobile/app/(app)/posts/[postId]/comments.tsx b/mobile/app/(app)/posts/[postId]/comments.tsx
new file mode 100644
index 0000000..0e573f0
--- /dev/null
+++ b/mobile/app/(app)/posts/[postId]/comments.tsx
@@ -0,0 +1,182 @@
+import { useCallback, useRef, useState } from "react";
+import { View, Text, FlatList, ActivityIndicator, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
+import { useLocalSearchParams, useRouter } from "expo-router";
+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 { PetAvatar } from "../../../../src/components/PetAvatar";
+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 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).
+ */
+export default function CommentsScreen() {
+ const { postId } = useLocalSearchParams<{ postId: string }>();
+ const router = useRouter();
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const { activePetId } = useActivePet();
+ const listRef = useRef>(null);
+
+ const [body, setBody] = useState("");
+ const [pendingBody, setPendingBody] = useState(null);
+
+ const { data, isLoading } = useQuery(trpc.comments.listByPost.queryOptions({ postId, limit: 50 }));
+ const comments = data?.comments ?? [];
+
+ const invalidateComments = () =>
+ queryClient.invalidateQueries({ queryKey: trpc.comments.listByPost.queryOptions({ postId, limit: 50 }).queryKey });
+
+ const createMutation = useMutation(
+ trpc.comments.create.mutationOptions({
+ onSuccess: async () => {
+ setPendingBody(null);
+ await invalidateComments();
+ setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
+ },
+ onError: () => {
+ setBody((prev) => prev || (pendingBody ?? ""));
+ setPendingBody(null);
+ },
+ })
+ );
+
+ const deleteMutation = useMutation(
+ trpc.comments.delete.mutationOptions({ onSuccess: () => invalidateComments() })
+ );
+
+ const handleSubmit = useCallback(() => {
+ if (!body.trim() || !activePetId) return;
+ const trimmed = body.trim();
+ setPendingBody(trimmed);
+ setBody("");
+ createMutation.mutate({ postId, petId: activePetId, body: trimmed });
+ }, [body, activePetId, postId, createMutation]);
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : (
+ item.id}
+ contentContainerStyle={styles.list}
+ ListEmptyComponent={
+
+ Noch keine Kommentare.
+
+ }
+ renderItem={({ item }) => (
+ activePetId && deleteMutation.mutate({ commentId: item.id, petId: activePetId })}
+ onPressPet={() => router.push(`/pets/${item.pet.id}`)}
+ />
+ )}
+ ListFooterComponent={
+ pendingBody ? (
+
+
+
+ Wird gesendet…
+ {pendingBody}
+
+
+ ) : null
+ }
+ />
+ )}
+
+ {activePetId && (
+
+
+
+
+ )}
+
+ );
+}
+
+type CommentRowProps = {
+ comment: PostComment;
+ isOwn: boolean;
+ onDelete: () => void;
+ onPressPet: () => void;
+};
+
+function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
+ return (
+
+
+
+
+
+
+
+ {formatPetName(comment.pet)}
+
+ {formatRelativeTime(comment.createdAt)}
+
+ {comment.body}
+
+ {isOwn && (
+
+
+
+ )}
+
+ );
+}
+
+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 },
+});
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 97f9886..6b0b471 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -18,8 +18,10 @@
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-dev-client": "~57.0.18",
+ "expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
"expo-image": "~57.0.4",
+ "expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
@@ -5755,6 +5757,16 @@
"expo": "*"
}
},
+ "node_modules/expo-file-system": {
+ "version": "57.0.6",
+ "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.6.tgz",
+ "integrity": "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-font": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.3.tgz",
@@ -5789,6 +5801,27 @@
}
}
},
+ "node_modules/expo-image-loader": {
+ "version": "57.0.1",
+ "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz",
+ "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
+ "node_modules/expo-image-picker": {
+ "version": "57.0.16",
+ "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.16.tgz",
+ "integrity": "sha512-9CrY/hoBHI2GsOo/4AWz6cInRHhGhhCmbza3JYr4j3P/NWpthLlIm0IXSWpxadSYJx0a2FAti0f7XWMg95aPEg==",
+ "license": "MIT",
+ "dependencies": {
+ "expo-image-loader": "~57.0.1"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-json-utils": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-57.0.1.tgz",
@@ -6169,16 +6202,6 @@
"node": ">=8"
}
},
- "node_modules/expo/node_modules/expo-file-system": {
- "version": "57.0.6",
- "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.6.tgz",
- "integrity": "sha512-pm8PMYEW6BnVOCBJ7df9FcDmQtE1tqImuYphlfYe1ipQRLYtdCayRczJcbKIsnB3mqEyp4gC2gWmMbsAsAOjVg==",
- "license": "MIT",
- "peerDependencies": {
- "expo": "*",
- "react-native": "*"
- }
- },
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "57.0.1",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.1.tgz",
diff --git a/mobile/package.json b/mobile/package.json
index 1de5666..4313576 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -13,8 +13,10 @@
"expo": "~57.0.18",
"expo-constants": "~57.0.16",
"expo-dev-client": "~57.0.18",
+ "expo-file-system": "~57.0.6",
"expo-font": "~57.0.3",
"expo-image": "~57.0.4",
+ "expo-image-picker": "~57.0.16",
"expo-linking": "~57.0.8",
"expo-router": "^5.1.11",
"expo-secure-store": "~57.0.2",
diff --git a/mobile/src/api/types.ts b/mobile/src/api/types.ts
index 39da881..a096d10 100644
--- a/mobile/src/api/types.ts
+++ b/mobile/src/api/types.ts
@@ -8,3 +8,4 @@ export type FeedPost = RouterOutputs["feed"]["getFeed"]["posts"][number];
export type ExplorePet = RouterOutputs["explore"]["listPets"]["pets"][number];
export type NotificationItem = RouterOutputs["notifications"]["list"]["notifications"][number];
export type Conversation = RouterOutputs["conversations"]["list"][number];
+export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][number];
diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx
index 28c6836..7e580cf 100644
--- a/mobile/src/components/PostCard.tsx
+++ b/mobile/src/components/PostCard.tsx
@@ -92,10 +92,10 @@ export function PostCard({ post, actingPetId }: Props) {
-
+ router.push(`/posts/${post.id}/comments`)}>
{post.commentCount ?? 0}
-
+
{post.repostCount ?? 0}
diff --git a/mobile/src/lib/upload.ts b/mobile/src/lib/upload.ts
new file mode 100644
index 0000000..eac9ff7
--- /dev/null
+++ b/mobile/src/lib/upload.ts
@@ -0,0 +1,22 @@
+import { File } from "expo-file-system";
+
+/**
+ * PUTs a local picked-image file directly to the Supabase Storage presigned
+ * URL returned by `posts.getPresignedUrl` — mirrors web's XHR PUT in
+ * `src/lib/upload-xhr.ts`, using expo-file-system's `File.upload` instead
+ * since RN has no XHR-with-progress primitive by default. Content-Type must
+ * exactly match what was passed to `getPresignedUrl` or Supabase responds
+ * with 400 SignatureDoesNotMatch.
+ */
+export async function uploadImageToSupabase(uri: string, presignedUrl: string, mimeType: string): Promise {
+ const file = new File(uri);
+ const result = await file.upload(presignedUrl, {
+ httpMethod: "PUT",
+ mimeType,
+ headers: { "Content-Type": mimeType },
+ });
+
+ if (result.status < 200 || result.status >= 300) {
+ throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
+ }
+}