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:
1 parent
8aa89e1011
commit
eda6d3b801
17 files changed
+934
-67
No files matched your search
+20
-13
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user