this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add post detail view

+124 -33
+13 -7
apps/expo/src/app/(app)/(home)/timeline.tsx
··· 61 61 <> 62 62 <Stack.Screen options={{ headerShown: true }} /> 63 63 <FlashList 64 + data={data} 65 + renderItem={({ item: { hasReply, item } }) => ( 66 + <FeedPost item={item} hasReply={hasReply} /> 67 + )} 68 + onEndReachedThreshold={0.5} 69 + onEndReached={() => void timeline.fetchNextPage()} 64 70 onRefresh={() => { 65 71 if (!timeline.isRefetching) void timeline.refetch(); 66 72 }} 67 73 refreshing={timeline.isRefetching} 68 - onEndReachedThreshold={0.5} 69 - onEndReached={() => void timeline.fetchNextPage()} 70 - data={data} 71 74 estimatedItemSize={91} 72 - renderItem={({ item: { hasReply, item } }) => ( 73 - <FeedPost item={item} hasReply={hasReply} /> 74 - )} 75 - keyExtractor={({ item }) => item.post.uri} 75 + ListFooterComponent={ 76 + timeline.isFetching ? ( 77 + <View className="w-full items-center py-4"> 78 + <ActivityIndicator /> 79 + </View> 80 + ) : null 81 + } 76 82 /> 77 83 </> 78 84 );
+99 -16
apps/expo/src/app/(app)/profile/[handle]/post/[id].tsx
··· 1 - import { ActivityIndicator, ScrollView, Text, View } from "react-native"; 1 + import { ActivityIndicator, Text, View } from "react-native"; 2 2 import { Stack, useLocalSearchParams } from "expo-router"; 3 - import { type AppBskyFeedDefs } from "@atproto/api"; 3 + import { AppBskyFeedDefs } from "@atproto/api"; 4 + import { FlashList } from "@shopify/flash-list"; 4 5 import { useQuery } from "@tanstack/react-query"; 5 6 7 + import { FeedPost } from "../../../../../components/feed-post"; 6 8 import { Post } from "../../../../../components/post"; 7 9 import { useAuthedAgent } from "../../../../../lib/agent"; 10 + import { assert } from "../../../../../lib/utils/assert"; 11 + 12 + type Posts = { 13 + post: AppBskyFeedDefs.PostView; 14 + primary: boolean; 15 + hasParent: boolean; 16 + hasReply: boolean; 17 + }; 8 18 9 19 export default function PostPage() { 10 20 const { handle, id } = useLocalSearchParams() as { ··· 21 31 } 22 32 const uri = `at://${did}/app.bsky.feed.post/${id}`; 23 33 const postThread = await agent.getPostThread({ uri }); 24 - return postThread.data.thread; 34 + 35 + const thread = postThread.data.thread; 36 + 37 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) 38 + throw Error("Post not found"); 39 + assert(AppBskyFeedDefs.validateThreadViewPost(thread)); 40 + 41 + const posts: Posts[] = []; 42 + 43 + // see if has parents 44 + const ancestors: Posts[] = []; 45 + 46 + let ancestor = thread; 47 + while (ancestor.parent) { 48 + if (!AppBskyFeedDefs.isThreadViewPost(ancestor.parent)) break; 49 + assert(AppBskyFeedDefs.validateThreadViewPost(ancestor.parent)); 50 + 51 + ancestors.push({ 52 + post: ancestor.parent.post, 53 + primary: false, 54 + hasParent: false, 55 + hasReply: true, 56 + }); 57 + 58 + ancestor = ancestor.parent; 59 + } 60 + 61 + const index = ancestors.length; 62 + ancestors.reverse(); 63 + posts.push(...ancestors); 64 + 65 + posts.push({ 66 + post: thread.post, 67 + primary: true, 68 + hasParent: !!thread.parent, 69 + hasReply: false, 70 + }); 71 + 72 + if (thread.replies) { 73 + for (const reply of thread.replies) { 74 + if (!AppBskyFeedDefs.isThreadViewPost(reply)) continue; 75 + assert(AppBskyFeedDefs.validateThreadViewPost(reply)); 76 + 77 + posts.push({ 78 + post: reply.post, 79 + primary: false, 80 + hasParent: false, 81 + hasReply: !!reply.replies?.[0], 82 + }); 83 + 84 + if (reply.replies && reply.replies[0]) { 85 + let child; 86 + child = reply.replies[0]; 87 + while (child) { 88 + if (!AppBskyFeedDefs.isThreadViewPost(child)) break; 89 + assert(AppBskyFeedDefs.validateThreadViewPost(child)); 90 + 91 + posts.push({ 92 + post: child.post, 93 + primary: false, 94 + hasParent: false, 95 + hasReply: !!child.replies?.[0], 96 + }); 97 + 98 + child = child.replies?.[0]; 99 + } 100 + } 101 + } 102 + } 103 + 104 + return { posts, index }; 25 105 }); 26 106 27 107 switch (thread.status) { ··· 42 122 </View> 43 123 ); 44 124 case "success": 45 - if (thread.data.notFound) { 46 - return ( 47 - <View className="flex-1 items-center justify-center p-4"> 48 - <Stack.Screen options={{ headerTitle: "Post" }} /> 49 - <Text className="text-center text-xl">Post not found</Text> 50 - </View> 51 - ); 52 - } 53 - const postThread = thread.data as AppBskyFeedDefs.ThreadViewPost; 54 - 55 125 return ( 56 - <ScrollView> 126 + <> 57 127 <Stack.Screen options={{ headerTitle: "Post" }} /> 58 - <Post post={postThread.post} /> 59 - </ScrollView> 128 + <FlashList 129 + data={thread.data.posts} 130 + initialScrollIndex={thread.data.index} 131 + // estimatedFirstItemOffset={thread.data.index * 91} 132 + estimatedItemSize={91} 133 + renderItem={({ item }) => 134 + item.primary ? ( 135 + <Post post={item.post} hasParent={item.hasParent} /> 136 + ) : ( 137 + <FeedPost item={{ post: item.post }} hasReply={item.hasReply} /> 138 + ) 139 + } 140 + ListFooterComponent={<View className="h-screen" />} 141 + /> 142 + </> 60 143 ); 61 144 } 62 145 }
+5 -5
apps/expo/src/components/post.tsx
··· 11 11 import { RichText } from "./rich-text"; 12 12 13 13 interface Props { 14 - post: AppBskyFeedDefs.ThreadViewPost["post"]; 15 - hasReply?: boolean; 14 + post: AppBskyFeedDefs.PostView; 15 + hasParent?: boolean; 16 16 } 17 17 18 - export const Post = ({ post, hasReply }: Props) => { 18 + export const Post = ({ post, hasParent }: Props) => { 19 19 const { liked, likeCount, toggleLike } = useLike(post); 20 20 const { reposted, repostCount, toggleRepost } = useRepost(post); 21 21 ··· 30 30 return ( 31 31 <View 32 32 className={cx( 33 - "bg-white p-4 pb-5", 34 - !hasReply && "border-b border-b-neutral-200", 33 + "border-b border-neutral-200 bg-white px-4 pb-4 pt-3", 34 + hasParent && "border-t", 35 35 )} 36 36 > 37 37 <Link href={profileHref} asChild>
+7 -5
apps/expo/src/components/profile-info.tsx
··· 34 34 /> 35 35 <View className="relative bg-white px-4 pb-4"> 36 36 <View className="h-10 flex-row items-center justify-end"> 37 - <Image 38 - source={{ uri: profile.avatar }} 39 - className="absolute -top-10 left-0 h-20 w-20 rounded-full border-4 border-white" 40 - alt="avatar image" 41 - /> 37 + <View className="absolute -top-10 left-0 rounded-full border-4 border-white"> 38 + <Image 39 + source={{ uri: profile.avatar }} 40 + className="h-20 w-20 rounded-full" 41 + alt="avatar image" 42 + /> 43 + </View> 42 44 {agent.session?.handle !== profile.handle && ( 43 45 <Button 44 46 disabled={toggleFollow.isLoading}