feat(mobile): add stories, video playback, and search

Story tray/creation/viewer with PawRing seen/unseen indicator, expo-video
playback for VIDEO posts (replacing the static placeholder), and a
pets/people/hashtags search screen reachable from the Explore tab header.
Extracts FollowButton from the pet-profile screen since search results
need the same follow/unfollow toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-06 08:53:07 +02:00
1 parent 8aa89e1011
commit eda6d3b801
17 files changed
+934 -67

No files matched your search

+20 -13
View File
@@ -13,8 +13,10 @@ architecture-decision thread in this session's handoff.
**Status:** Phase 1 — real Feed/Explore/Notifications/Pets tabs against the
live backend, not just a connectivity scaffold anymore. Post creation,
comments, stories, pet profile detail, video playback, search, health, and
message sending are still not built — see "What's not built yet" below.
comments, repost, pet profile detail (view/follow/edit/followers-following),
stories (tray/creation/viewer), video playback, and search are done and
verified end-to-end against the live backend. Health tracking, milestones,
and message sending are still not built — see "What's not built yet" below.
Android is the priority platform; iOS builds need a Mac + Apple Developer
account and come later (same codebase, no separate source tree — see
"Android-first, then iOS" below).
@@ -153,21 +155,25 @@ mobile/
(auth)/
sign-in.tsx # email/password + Clerk Device Trust email-code step
(app)/
_layout.tsx # Stack hosting the (tabs) group + the messages screen
_layout.tsx # Stack hosting the (tabs) group + the messages/search/story/comment screens
(tabs)/
_layout.tsx # bottom Tabs: Feed/Explore/Notifications/Pets + header Messages button
index.tsx # Feed
_layout.tsx # bottom Tabs: Feed/Explore/Notifications/Pets + header Search/Messages buttons
index.tsx # Feed (StoryTray header + infinite post list)
explore.tsx # pet discovery grid
notifications.tsx # notifications list
pets.tsx # owner's pets + active-pet switcher
messages/index.tsx # conversations list
search.tsx # pets/people/hashtags search, three tabs over one debounced query
post-new.tsx # create a photo post (pick/upload → posts.create)
story-new.tsx # create a photo story (pick/upload → stories.create)
story-viewer.tsx # full-screen story playback (tap zones, progress bars, view tracking)
posts/[postId]/comments.tsx # read/write/delete comments for a post
image-viewer.tsx # full-screen tapped-image lightbox
src/
api/trpc.ts # tRPC client, AppRouter type import, query client factory
api/types.ts # inferRouterOutputs<AppRouter> shortcuts (Pet, FeedPost, ...)
context/ActivePetContext.tsx # MMKV-backed active-pet state
hooks/useDebounce.ts # generic debounce hook (search input)
lib/env.ts # typed EXPO_PUBLIC_* access
lib/tokenCache.ts # Clerk session persistence via expo-secure-store
lib/storage.ts # MMKV instance + TanStack Query sync persister
@@ -176,7 +182,8 @@ mobile/
lib/sponsor.ts # active-sponsor check
lib/upload.ts # direct PUT of a picked image to a presigned Supabase URL
theme/tokens.ts # design tokens mirroring the web app's DESIGN.md
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar, PetIdentityBadges
components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar,
# PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer
```
## Android-first, then iOS
@@ -199,14 +206,14 @@ separately. "Starting with Android" means:
Phase 1 (Feed/Explore/Notifications/Pets tabs, reactions, active-pet
switching, persisted cache for fast reopen) plus a full pet profile
(view/follow/edit/followers-following), post creation/upload, comments, and
repost ("Paw-Back") creation are done and verified end-to-end against the
live backend on a real Android emulator. Still missing, roughly in the
order they'd unblock the most:
(view/follow/edit/followers-following), post creation/upload, comments,
repost ("Paw-Back") creation, stories (tray/creation/viewer via
`expo-video`-adjacent `expo-image`, plus `PawRing` seen/unseen indicator),
video playback (`expo-video` against Mux HLS, polling while `PROCESSING`),
and search (pets/people/hashtags, `search.pets`/`byOwner`/`byHashtag`) are
done and verified end-to-end against the live backend on a real Android
emulator. Still missing, roughly in the order they'd unblock the most:
- Stories (story ring, viewer, creation)
- Video playback (currently a static placeholder for `type === "VIDEO"` posts)
- Search (`search.pets`/`byHashtag`/`byOwner`) — separate from the Explore tab
- Health tracking, milestones
- Sending messages (conversations list works; no chat detail/compose screen)
- Push notifications
+2 -1
View File
@@ -38,7 +38,8 @@
"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."
}
]
],
"expo-video"
]
}
}
+24 -4
View File
@@ -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, Plus } from "lucide-react-native";
import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus, Search } from "lucide-react-native";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { colors, fontFamily } from "../../../src/theme/tokens";
@@ -10,8 +10,10 @@ import { colors, fontFamily } from "../../../src/theme/tokens";
* Bottom tab bar mirroring the product's real mobile IA (web's
* src/components/layout/MobileNav.tsx): Feed, Explore, Notifications, Pets.
* Messages is a header button, not a tab, matching web (there it's a fixed
* floating corner button). The center "+" post button from web has no
* equivalent here yet — post creation is a later phase.
* floating corner button). "+" (new post, Feed tab) and the search icon
* (Explore tab) have no web mobile-nav equivalent at all — web only exposes
* search via the desktop Sidebar — so these are this app's own entry points
* for actions that need one.
*/
export default function TabsLayout() {
return (
@@ -43,7 +45,16 @@ export default function TabsLayout() {
/>
<Tabs.Screen
name="explore"
options={{ title: "Explore", tabBarIcon: ({ color, size }) => <Compass color={color} size={size} /> }}
options={{
title: "Explore",
headerRight: () => (
<View style={styles.headerButtons}>
<SearchHeaderButton />
<MessagesHeaderButton />
</View>
),
tabBarIcon: ({ color, size }) => <Compass color={color} size={size} />,
}}
/>
<Tabs.Screen
name="notifications"
@@ -76,6 +87,15 @@ function NewPostHeaderButton() {
);
}
function SearchHeaderButton() {
const router = useRouter();
return (
<Pressable onPress={() => router.push("/search")} hitSlop={12}>
<Search color={colors.ink} size={22} />
</Pressable>
);
}
function MessagesHeaderButton() {
const router = useRouter();
return (
+5 -2
View File
@@ -3,12 +3,14 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../src/api/trpc";
import { useActivePet } from "../../../src/context/ActivePetContext";
import { PostCard } from "../../../src/components/PostCard";
import { StoryTray } from "../../../src/components/StoryTray";
import { colors, typography } from "../../../src/theme/tokens";
import type { FeedPost } from "../../../src/api/types";
/**
* Port of web's src/components/feed/FeedList.tsx, scoped to Phase 1: real
* infinite-scroll feed data, no ads/story tray/feed-mode toggle yet.
* Port of web's src/components/feed/FeedList.tsx: infinite-scroll feed data
* with the StoryTray as a list header. No ads or feed-mode ("algo" vs
* "chrono") toggle yet.
*/
export default function FeedScreen() {
const trpc = useTRPC();
@@ -47,6 +49,7 @@ export default function FeedScreen() {
keyExtractor={(post) => post.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => <PostCard post={item} actingPetId={activePetId!} />}
ListHeaderComponent={<StoryTray />}
ItemSeparatorComponent={() => (
<View style={styles.separatorWrap}>
<View style={styles.separatorLine} />
+14
View File
@@ -28,6 +28,7 @@ export default function AppLayout() {
<Stack.Screen name="pets/[petId]/followers" options={{ ...headerOptions, title: "Follower" }} />
<Stack.Screen name="pets/[petId]/following" options={{ ...headerOptions, title: "Folgt" }} />
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: "Kommentare" }} />
<Stack.Screen name="search" options={{ ...headerOptions, title: "Suche" }} />
<Stack.Screen
name="post-new"
options={{
@@ -37,10 +38,23 @@ export default function AppLayout() {
headerLeft: () => <CloseModalButton />,
}}
/>
<Stack.Screen
name="story-new"
options={{
...headerOptions,
title: "Neue Story",
presentation: "modal",
headerLeft: () => <CloseModalButton />,
}}
/>
<Stack.Screen
name="image-viewer"
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade" }}
/>
<Stack.Screen
name="story-viewer"
options={{ headerShown: false, presentation: "fullScreenModal", animation: "fade" }}
/>
</Stack>
);
}
+3 -35
View File
@@ -1,13 +1,13 @@
import { useState } from "react";
import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../../../../src/api/trpc";
import { useActivePet } from "../../../../src/context/ActivePetContext";
import { PetAvatar } from "../../../../src/components/PetAvatar";
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 { formatPetName } from "../../../../src/lib/format";
import { colors, typography } from "../../../../src/theme/tokens";
@@ -23,7 +23,6 @@ export default function PetProfileScreen() {
const { petId } = useLocalSearchParams<{ petId: string }>();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const { data: pet, isLoading, error } = useQuery(trpc.pets.getProfile.queryOptions({ petId }));
@@ -33,27 +32,6 @@ export default function PetProfileScreen() {
const isOwnPet = activePetId === petId;
const { data: isFollowing } = useQuery({
...trpc.follows.isFollowing.queryOptions({ followerPetId: activePetId ?? "", followeePetId: petId }),
enabled: !isOwnPet && !!activePetId,
});
const [followPending, setFollowPending] = useState(false);
const followMutation = useMutation(
trpc.follows.create.mutationOptions({
onMutate: () => setFollowPending(true),
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
onSettled: () => setFollowPending(false),
})
);
const unfollowMutation = useMutation(
trpc.follows.delete.mutationOptions({
onMutate: () => setFollowPending(true),
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
onSettled: () => setFollowPending(false),
})
);
if (isLoading) {
return (
<View style={styles.center}>
@@ -106,17 +84,7 @@ export default function PetProfileScreen() {
{isOwnPet ? (
<Button title="Bearbeiten" variant="outline" onPress={() => router.push(`/pets/${petId}/edit`)} />
) : (
<Button
title={isFollowing ? "Entfolgen" : "Folgen"}
variant={isFollowing ? "outline" : "primary"}
disabled={followPending || !activePetId}
onPress={() => {
if (!activePetId) return;
const params = { followerPetId: activePetId, followeePetId: petId };
if (isFollowing) unfollowMutation.mutate(params);
else followMutation.mutate(params);
}}
/>
activePetId && <FollowButton followerPetId={activePetId} followeePetId={petId} />
)}
{pet.bio ? <Text style={styles.section}>{pet.bio}</Text> : null}
+242
View File
@@ -0,0 +1,242 @@
import { useState } from "react";
import { View, Text, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Search as SearchIcon, Layers2 } from "lucide-react-native";
import { Image } from "expo-image";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { useDebounce } from "../../src/hooks/useDebounce";
import { TextField } from "../../src/components/TextField";
import { PetAvatar } from "../../src/components/PetAvatar";
import { FollowButton } from "../../src/components/FollowButton";
import { getMediaUrl } from "../../src/lib/media-url";
import { colors, typography, radii } from "../../src/theme/tokens";
type Tab = "pets" | "people" | "tags";
type SearchPet = {
id: string;
name: string;
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
};
/**
* Port of web's src/app/(app)/search/page.tsx: three tabs (pets/people/tags)
* over one debounced query. Reached via the header search icon on the
* Explore tab — web's own mobile nav (MobileNav.tsx) has no search entry
* either, so there's no existing mobile pattern to match here.
*/
export default function SearchScreen() {
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
const [query, setQuery] = useState("");
const [tab, setTab] = useState<Tab>("pets");
const debouncedQuery = useDebounce(query, 300);
const trimmed = debouncedQuery.trim();
const enabled = trimmed.length >= 2;
const normalizedTag = trimmed.replace(/^#/, "").toLowerCase();
const { data: petResults, isLoading: loadingPets } = useQuery({
...trpc.search.pets.queryOptions({ query: trimmed }),
enabled: enabled && tab === "pets",
});
const { data: ownerResults, isLoading: loadingPeople } = useQuery({
...trpc.search.byOwner.queryOptions({ query: trimmed }),
enabled: enabled && tab === "people",
});
const { data: tagResults, isLoading: loadingTags } = useQuery({
...trpc.search.byHashtag.queryOptions({ tag: normalizedTag }),
enabled: normalizedTag.length >= 1 && tab === "tags",
});
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<SearchIcon size={16} color={colors.quietGray} style={styles.searchIcon} />
<TextField
placeholder="Suchen…"
value={query}
onChangeText={setQuery}
autoFocus
style={styles.searchInput}
/>
</View>
<View style={styles.tabs}>
<TabButton label="Tiere" active={tab === "pets"} onPress={() => setTab("pets")} />
<TabButton label="Personen" active={tab === "people"} onPress={() => setTab("people")} />
<TabButton label="Hashtags" active={tab === "tags"} onPress={() => setTab("tags")} />
</View>
{tab === "pets" &&
(!enabled ? (
<EmptyState text="Mindestens 2 Zeichen eingeben." />
) : loadingPets ? (
<Loading />
) : (
<FlatList
data={petResults?.pets ?? []}
keyExtractor={(pet) => pet.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<EmptyState text={`Keine Tiere für "${trimmed}" gefunden.`} />}
renderItem={({ item }) => (
<PetRow pet={item} activePetId={activePetId} onPress={() => router.push(`/pets/${item.id}`)} />
)}
/>
))}
{tab === "people" &&
(!enabled ? (
<EmptyState text="Mindestens 2 Zeichen eingeben." />
) : loadingPeople ? (
<Loading />
) : (
<FlatList
data={ownerResults?.owners ?? []}
keyExtractor={(owner) => owner.id}
contentContainerStyle={styles.list}
ListEmptyComponent={<EmptyState text={`Keine Personen für "${trimmed}" gefunden.`} />}
renderItem={({ item: owner }) => {
const displayName = owner.username
? `@${owner.username}`
: [owner.firstName, owner.lastName].filter(Boolean).join(" ") || "Unbekannt";
return (
<View style={styles.ownerBlock}>
<Text style={styles.ownerName}>{displayName}</Text>
{owner.pets.map((pet) => (
<PetRow
key={pet.id}
pet={pet}
activePetId={activePetId}
onPress={() => router.push(`/pets/${pet.id}`)}
indented
/>
))}
</View>
);
}}
/>
))}
{tab === "tags" &&
(normalizedTag.length < 1 ? (
<EmptyState text="Hashtag eingeben." />
) : loadingTags ? (
<Loading />
) : (
<FlatList
data={tagResults?.posts ?? []}
keyExtractor={(post) => post.id}
numColumns={3}
contentContainerStyle={styles.tagGrid}
ListEmptyComponent={<EmptyState text={`Keine Beiträge für #${normalizedTag}.`} />}
renderItem={({ item: post }) => {
const firstImage = post.images[0];
const isCarousel = post.images.length > 1;
return (
<View style={styles.tagTile}>
{firstImage ? (
<Image
source={{ uri: getMediaUrl(firstImage.storageKey) }}
style={styles.tagImage}
contentFit="cover"
placeholder={firstImage.blurhash ? { blurhash: firstImage.blurhash } : undefined}
/>
) : (
<View style={[styles.tagImage, { backgroundColor: colors.hairlineGray }]} />
)}
{isCarousel && (
<View style={styles.tagCarouselBadge}>
<Layers2 size={14} color={colors.pureWhite} />
</View>
)}
</View>
);
}}
/>
))}
</View>
);
}
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
return (
<Pressable style={[styles.tabButton, active && styles.tabButtonActive]} onPress={onPress}>
<Text style={[styles.tabButtonText, active && styles.tabButtonTextActive]}>{label}</Text>
</Pressable>
);
}
function PetRow({
pet,
activePetId,
onPress,
indented,
}: {
pet: SearchPet;
activePetId: string | null;
onPress: () => void;
indented?: boolean;
}) {
return (
<View style={[styles.petRow, indented && styles.petRowIndented]}>
<Pressable style={styles.petRowMain} onPress={onPress}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
<View style={styles.petRowText}>
<Text style={styles.petName}>{pet.name}</Text>
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
</View>
</Pressable>
{activePetId && activePetId !== pet.id && (
<FollowButton followerPetId={activePetId} followeePetId={pet.id} compact />
)}
</View>
);
}
function Loading() {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.marigoldOrange} />
</View>
);
}
function EmptyState({ text }: { text: string }) {
return (
<View style={styles.center}>
<Text style={typography.body}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.porchWhite },
searchBar: { paddingHorizontal: 16, paddingTop: 12, justifyContent: "center" },
searchIcon: { position: "absolute", left: 28, zIndex: 1 },
searchInput: { paddingLeft: 36 },
tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 },
tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray },
tabButtonActive: { backgroundColor: colors.marigoldOrange },
tabButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
tabButtonTextActive: { color: colors.pureWhite },
list: { padding: 16, flexGrow: 1 },
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
petRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 8 },
petRowIndented: { marginLeft: 16 },
petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 },
petRowText: { flex: 1 },
petName: { ...typography.body, fontFamily: typography.title.fontFamily },
petSpecies: { ...typography.label, textTransform: "none", letterSpacing: 0 },
ownerBlock: { marginBottom: 16 },
ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 },
tagGrid: { padding: 2 },
tagTile: { flex: 1, aspectRatio: 1, padding: 2 },
tagImage: { width: "100%", height: "100%", borderRadius: radii.sm },
tagCarouselBadge: { position: "absolute", top: 6, right: 6 },
});
+134
View File
@@ -0,0 +1,134 @@
import { useState, useCallback } from "react";
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import * as ImagePicker from "expo-image-picker";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, Camera } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { uploadImageToSupabase } from "../../src/lib/upload";
import { Button } from "../../src/components/Button";
import { colors, radii, typography } from "../../src/theme/tokens";
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
/**
* Photo-only story creation (matches web's own "Phase 2" note in
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
* flow web itself only added later, not carried over here). Single image,
* unlike post-new.tsx's multi-image picker — a story is one photo.
*/
export default function NewStoryScreen() {
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
const [error, setError] = useState<string | null>(null);
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
const createMutation = useMutation(
trpc.stories.create.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.follows.pathFilter());
await queryClient.invalidateQueries(trpc.stories.pathFilter());
router.back();
},
onError: (err) => setError(err.message),
})
);
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"], quality: 0.8 });
if (!result.canceled) setAsset(result.assets[0]);
}, []);
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) setAsset(result.assets[0]);
}, []);
const handleSubmit = useCallback(async () => {
if (!asset || !activePetId) return;
setError(null);
const mimeType = ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "") ? (asset.mimeType as string) : "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);
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
} catch {
setError("Upload fehlgeschlagen — bitte erneut versuchen.");
}
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
return (
<View style={styles.container}>
{asset ? (
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
<Text style={styles.previewHint}>Antippen, um ein anderes Foto zu wählen</Text>
</Pressable>
) : (
<View style={styles.pickerRow}>
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
<ImagePlus color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Galerie</Text>
</Pressable>
<Pressable style={styles.pickerButton} onPress={takePhoto}>
<Camera color={colors.marigoldOrange} size={22} />
<Text style={styles.pickerText}>Kamera</Text>
</Pressable>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={isSubmitting ? "Wird geteilt…" : "Story teilen"}
onPress={handleSubmit}
disabled={!asset || isSubmitting}
/>
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite },
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 },
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
previewImage: { width: "100%", flex: 1 },
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
error: { ...typography.body, color: colors.alertRed },
});
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { View, Text, Pressable, StyleSheet } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { X } from "lucide-react-native";
import { useTRPC } from "../../src/api/trpc";
import { useActivePet } from "../../src/context/ActivePetContext";
import { PetAvatar } from "../../src/components/PetAvatar";
import { getMediaUrl } from "../../src/lib/media-url";
import { colors } from "../../src/theme/tokens";
const STORY_DURATION_MS = 5000;
/**
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
* stories only (no Mux video story playback — see story-new.tsx). Also
* drops the owner-only viewer-count/list Sheet and the paw-reaction/comment
* overlay (Gitea #29 additions on web) — fast-follows, not core to "view a
* story". Tap the left third to go back a story, the rest to skip ahead;
* a fixed timer still auto-advances either way.
*/
export default function StoryViewerScreen() {
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
petId: string;
petName: string;
petAvatarKey?: string;
}>();
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const { data: stories = [] } = useQuery(trpc.stories.listActive.queryOptions({ petId }));
const [index, setIndex] = useState(0);
const [progress, setProgress] = useState(0);
const advancedRef = useRef(false);
const currentStory = stories[index];
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
const close = useCallback(() => {
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
queryClient.invalidateQueries(trpc.follows.pathFilter());
router.back();
}, [queryClient, trpc, petId, router]);
const advance = useCallback(() => {
if (advancedRef.current) return;
advancedRef.current = true;
if (index < stories.length - 1) {
setIndex((i) => i + 1);
} else {
close();
}
}, [index, stories.length, close]);
const goBack = useCallback(() => {
if (index === 0) return;
advancedRef.current = true;
setIndex((i) => i - 1);
}, [index]);
// Record a deduplicated view once this story is on screen.
useEffect(() => {
if (!currentStory || !activePetId || activePetId === petId) return;
recordViewMutation.mutate({ storyId: currentStory.id, viewerPetId: activePetId });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStory?.id, activePetId]);
// Fixed-duration progress bar, auto-advances/closes when it fills.
useEffect(() => {
if (!currentStory) return;
setProgress(0);
advancedRef.current = false;
const startTime = Date.now();
let rafId: number;
function tick() {
const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100);
setProgress(pct);
if (pct < 100) rafId = requestAnimationFrame(tick);
else advance();
}
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [currentStory, advance]);
if (!currentStory) return null;
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
return (
<View style={styles.backdrop}>
<View style={styles.progressRow}>
{stories.map((s, i) => (
<View key={s.id} style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${i < index ? 100 : i === index ? progress : 0}%` },
]}
/>
</View>
))}
</View>
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
<View style={styles.tapZones}>
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
<Pressable style={styles.tapZoneRight} onPress={advance} />
</View>
<View style={styles.petInfo}>
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
<Text style={styles.petName}>{petName}</Text>
</View>
<Pressable style={styles.closeButton} onPress={close} hitSlop={12}>
<X color={colors.pureWhite} size={22} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: colors.ink },
media: { flex: 1, width: "100%" },
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
tapZoneLeft: { flex: 1 },
tapZoneRight: { flex: 2 },
progressRow: { position: "absolute", top: 12, left: 12, right: 12, flexDirection: "row", gap: 4, zIndex: 2 },
progressTrack: { flex: 1, height: 2, borderRadius: 1, backgroundColor: "rgba(255,255,255,0.3)", overflow: "hidden" },
progressFill: { height: "100%", backgroundColor: colors.marigoldOrange },
petInfo: {
position: "absolute",
bottom: 24,
left: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
zIndex: 2,
},
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
closeButton: {
position: "absolute",
top: 32,
right: 16,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(0,0,0,0.4)",
alignItems: "center",
justifyContent: "center",
zIndex: 2,
},
});
+12
View File
@@ -27,6 +27,7 @@
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"expo-video": "~57.0.3",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -6015,6 +6016,17 @@
"expo": "*"
}
},
"node_modules/expo-video": {
"version": "57.0.3",
"resolved": "https://registry.npmjs.org/expo-video/-/expo-video-57.0.3.tgz",
"integrity": "sha512-Z+rLdBSzICwoHm/HUxND5fm5nfgqiB+QPWAOKxmA8ScYwvxG1UGjZ6OaayvPc3GkT4aucsbycmFO0uUv/qnIhg==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": {
"version": "57.0.21",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.21.tgz",
+1
View File
@@ -22,6 +22,7 @@
"expo-secure-store": "~57.0.2",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.3",
"expo-video": "~57.0.3",
"lucide-react-native": "^1.41.0",
"react": "19.2.3",
"react-dom": "19.2.3",
+59
View File
@@ -0,0 +1,59 @@
import { useState } from "react";
import { StyleSheet } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
import { Button } from "./Button";
type Props = {
followerPetId: string;
followeePetId: string;
/** Compact 36px pill for list rows (search results) instead of the full 48px button used standalone on the pet profile screen. */
compact?: boolean;
};
/**
* Extracted from the inline follow/unfollow logic in pets/[petId]/index.tsx
* now that a second call site (search results) needs the same toggle.
*/
export function FollowButton({ followerPetId, followeePetId, compact }: Props) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: isFollowing } = useQuery(
trpc.follows.isFollowing.queryOptions({ followerPetId, followeePetId })
);
const [pending, setPending] = useState(false);
const followMutation = useMutation(
trpc.follows.create.mutationOptions({
onMutate: () => setPending(true),
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
onSettled: () => setPending(false),
})
);
const unfollowMutation = useMutation(
trpc.follows.delete.mutationOptions({
onMutate: () => setPending(true),
onSuccess: () => queryClient.invalidateQueries(trpc.follows.pathFilter()),
onSettled: () => setPending(false),
})
);
return (
<Button
title={isFollowing ? "Entfolgen" : "Folgen"}
variant={isFollowing ? "outline" : "primary"}
disabled={pending}
onPress={() => {
const params = { followerPetId, followeePetId };
if (isFollowing) unfollowMutation.mutate(params);
else followMutation.mutate(params);
}}
style={compact ? styles.compact : undefined}
/>
);
}
const styles = StyleSheet.create({
compact: { height: 36, paddingHorizontal: 14 },
});
+67
View File
@@ -0,0 +1,67 @@
import { View, Pressable } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "../api/trpc";
import { useActivePet } from "../context/ActivePetContext";
type Props = {
petId: string;
children: React.ReactNode;
/** Avatar size in px — used to scale the paw outline (default: 32) */
size?: number;
onPress?: () => void;
};
const RING_PAD = 8;
const UNSEEN_COLOR = "#f97316";
const SEEN_COLOR = "#9ca3af";
/**
* Port of web's src/components/stories/PawRing.tsx — a paw-print outline
* (one big circle + four "toe bean" circles) drawn with react-native-svg
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
* no ring = no active story (STORY-02).
*/
export function PawRing({ petId, children, size = 32, onPress }: Props) {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const { data = { hasStory: false, allSeen: false } } = useQuery(
trpc.stories.hasActiveStory.queryOptions({ petId, viewerPetId: activePetId ?? undefined })
);
const { hasStory, allSeen } = data;
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
const outerSize = size + RING_PAD * 2;
const strokeWidth = Math.max(4, size / 8);
const beans = [
{ cx: 13, cy: 10, r: 8 },
{ cx: 31, cy: 1, r: 10 },
{ cx: 69, cy: 1, r: 10 },
{ cx: 87, cy: 10, r: 8 },
];
const content = (
<View style={{ width: outerSize, height: outerSize, alignItems: "center", justifyContent: "center" }}>
{hasStory && (
<Svg
width={outerSize}
height={outerSize}
viewBox="0 0 100 100"
style={{ position: "absolute" }}
>
<Circle cx={50} cy={50} r={44} fill="none" stroke={ringColor} strokeWidth={strokeWidth} />
{beans.map((b, i) => (
<Circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill={ringColor} />
))}
</Svg>
)}
{children}
</View>
);
if (hasStory && onPress) {
return <Pressable onPress={onPress}>{content}</Pressable>;
}
return content;
}
+10 -12
View File
@@ -1,12 +1,13 @@
import { View, Text, Pressable, StyleSheet, ScrollView, useWindowDimensions } from "react-native";
import { useRouter } from "expo-router";
import { Image } from "expo-image";
import { MessageCircle, Video } from "lucide-react-native";
import { MessageCircle } from "lucide-react-native";
import { PetAvatar } from "./PetAvatar";
import { PetIdentityBadges } from "./PetIdentityBadges";
import { PawButton } from "./PawButton";
import { PawBackButton } from "./PawBackButton";
import { RepostCard } from "./RepostCard";
import { VideoPlayer } from "./VideoPlayer";
import { getMediaUrl } from "../lib/media-url";
import { formatPetName, formatRelativeTime } from "../lib/format";
import { colors, typography, radii } from "../theme/tokens";
@@ -44,6 +45,7 @@ export type PostCardPost = {
repostCount?: number | null;
images: PostCardImage[];
repost?: { originalPost: PostCardOriginalPost } | null;
videoPost?: { status: "PROCESSING" | "READY" | "ERROR"; muxPlaybackId?: string | null } | null;
pet: {
id: string;
name: string;
@@ -96,10 +98,13 @@ export function PostCard({ post, actingPetId }: Props) {
</View>
</Pressable>
{post.type === "VIDEO" ? (
<View style={[styles.videoPlaceholder, { width: cardWidth }]}>
<Video size={32} color={colors.pureWhite} />
</View>
{post.type === "VIDEO" && post.videoPost ? (
<VideoPlayer
postId={post.id}
initialStatus={post.videoPost.status}
initialPlaybackId={post.videoPost.muxPlaybackId}
width={cardWidth}
/>
) : (
<PostImages images={post.images} width={cardWidth} onPressImage={openViewer} />
)}
@@ -168,11 +173,4 @@ const styles = StyleSheet.create({
actions: { flexDirection: "row", alignItems: "center", gap: 20 },
actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 },
actionCount: { ...typography.body, color: colors.quietGray, fontSize: 13 },
videoPlaceholder: {
aspectRatio: 9 / 16,
borderRadius: radii.md,
backgroundColor: colors.ink,
alignItems: "center",
justifyContent: "center",
},
});
+77
View File
@@ -0,0 +1,77 @@
import { ScrollView, View, Text, Pressable, StyleSheet } from "react-native";
import { useRouter } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { PlusCircle } from "lucide-react-native";
import { useTRPC } from "../api/trpc";
import { useActivePet } from "../context/ActivePetContext";
import { PawRing } from "./PawRing";
import { PetAvatar } from "./PetAvatar";
import { colors, fontFamily } from "../theme/tokens";
const AVATAR_SIZE = 56;
/**
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
* for the active pet, then followed pets with an active story (each in a
* PawRing). No viewer list, reactions, or comments on stories yet (Gitea
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
* AI-disclosure/mentions from ported forms. Photo stories only, no video
* story upload/playback (mirrors mobile's post-creation scope decision).
*/
export function StoryTray() {
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
const { data: followedWithStories = [] } = useQuery({
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
enabled: !!activePetId,
});
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
<Pressable style={styles.item} onPress={() => router.push("/story-new")}>
<View style={styles.addButton}>
<PlusCircle color={colors.pureWhite} size={26} />
</View>
<Text style={styles.label} numberOfLines={1}>
Story
</Text>
</Pressable>
{followedWithStories.map((pet) => (
<Pressable
key={pet.id}
style={styles.item}
onPress={() =>
router.push({
pathname: "/story-viewer",
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
})
}
>
<PawRing petId={pet.id} size={AVATAR_SIZE}>
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
</PawRing>
<Text style={styles.label} numberOfLines={1}>
{pet.name}
</Text>
</Pressable>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
row: { gap: 16, paddingHorizontal: 16, paddingVertical: 12 },
item: { alignItems: "center", gap: 4, width: 72 },
addButton: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: colors.marigoldOrange,
alignItems: "center",
justifyContent: "center",
},
label: { fontFamily: fontFamily.regular, fontSize: 11, color: colors.ink },
});
+92
View File
@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle } from "lucide-react-native";
import { useVideoPlayer, VideoView } from "expo-video";
import { useTRPC } from "../api/trpc";
import { colors, radii, typography } from "../theme/tokens";
type Status = "PROCESSING" | "READY" | "ERROR";
type Props = {
postId: string;
/** Initial status from the feed/profile query — avoids a redundant fetch when already READY */
initialStatus: Status;
initialPlaybackId?: string | null;
width: number;
};
/**
* Port of web's src/components/video/VideoCard.tsx. Mux serves HLS, which
* expo-video plays natively (AVPlayer/ExoPlayer under the hood) — no
* separate player SDK needed the way web needs @mux/mux-player-react.
*/
export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width }: Props) {
const trpc = useTRPC();
const queryClient = useQueryClient();
// Poll while PROCESSING — stop once READY or ERROR
const { data } = useQuery({
...trpc.videos.getByPostId.queryOptions({ postId }),
enabled: initialStatus === "PROCESSING",
refetchInterval: (query) => (query.state.data?.status === "PROCESSING" ? 5000 : false),
});
const status = data?.status ?? initialStatus;
const playbackId = data?.muxPlaybackId ?? initialPlaybackId;
useEffect(() => {
if (status === "READY" && initialStatus === "PROCESSING") {
queryClient.invalidateQueries(trpc.feed.pathFilter());
queryClient.invalidateQueries(trpc.posts.pathFilter());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, initialStatus]);
if (status === "PROCESSING") {
return (
<View style={[styles.box, { width }]}>
<ActivityIndicator color={colors.marigoldOrange} />
<Text style={styles.label}>Video wird verarbeitet…</Text>
</View>
);
}
if (status === "ERROR" || !playbackId) {
return (
<View style={[styles.box, { width }]}>
<AlertCircle color={colors.alertRed} size={28} />
<Text style={styles.label}>Video konnte nicht verarbeitet werden.</Text>
</View>
);
}
return <ReadyVideo playbackId={playbackId} width={width} />;
}
function ReadyVideo({ playbackId, width }: { playbackId: string; width: number }) {
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
p.loop = true;
});
return (
<VideoView
player={player}
style={{ width, aspectRatio: 16 / 9, borderRadius: radii.md, backgroundColor: colors.ink }}
nativeControls
contentFit="contain"
/>
);
}
const styles = StyleSheet.create({
box: {
aspectRatio: 16 / 9,
borderRadius: radii.md,
backgroundColor: colors.ink,
alignItems: "center",
justifyContent: "center",
gap: 8,
},
label: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.pureWhite },
});
+13
View File
@@ -0,0 +1,13 @@
import { useEffect, useState } from "react";
/** Port of web's src/hooks/use-debounce.ts. */
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}