this repo has no description
0
fork

Configure Feed

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

liking and reposting

+280 -123
+3 -3
apps/expo/src/app/(app)/_layout.tsx
··· 1 1 import { Stack, Tabs } from "expo-router"; 2 2 3 - import { useAuthedAgent } from "../../lib/agent"; 3 + import { useAgent } from "../../lib/agent"; 4 4 5 5 export default function AppLayout() { 6 - const agent = useAuthedAgent(); 6 + const agent = useAgent(); 7 7 return ( 8 8 <> 9 9 <Stack.Screen 10 - options={{ headerTitle: agent.session.handle, animation: "none" }} 10 + options={{ headerTitle: agent.session?.handle, animation: "none" }} 11 11 /> 12 12 <Tabs screenOptions={{ headerShown: false }} /> 13 13 </>
-119
apps/expo/src/app/(app)/app.tsx
··· 1 - import { ActivityIndicator, Text, View } from "react-native"; 2 - // import { Tabs } from "expo-router"; 3 - import { type AppBskyFeedPost } from "@atproto/api"; 4 - import { type FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 5 - import { FlashList } from "@shopify/flash-list"; 6 - import { useInfiniteQuery } from "@tanstack/react-query"; 7 - import { Heart, MessageSquare, Repeat } from "lucide-react-native"; 8 - 9 - import { Button } from "../../components/button"; 10 - import { useAuthedAgent } from "../../lib/agent"; 11 - 12 - function Timeline() { 13 - const agent = useAuthedAgent(); 14 - const timeline = useInfiniteQuery({ 15 - queryKey: ["timeline"], 16 - queryFn: async ({ pageParam }) => { 17 - const timeline = await agent.getTimeline({ 18 - limit: 5, 19 - cursor: pageParam as string | undefined, 20 - }); 21 - return timeline.data; 22 - }, 23 - getNextPageParam: (lastPage) => lastPage.cursor, 24 - }); 25 - 26 - switch (timeline.status) { 27 - case "loading": 28 - return ( 29 - <View className="flex-1 items-center justify-center"> 30 - <ActivityIndicator size="large" /> 31 - </View> 32 - ); 33 - 34 - case "error": 35 - return ( 36 - <View className="flex-1 items-center justify-center p-4"> 37 - <Text className="text-center text-xl"> 38 - {(timeline.error as Error).message || "An error occurred"} 39 - </Text> 40 - <Button variant="outline" onPress={() => void timeline.refetch()}> 41 - Retry 42 - </Button> 43 - </View> 44 - ); 45 - 46 - case "success": 47 - return ( 48 - <FlashList 49 - onEndReached={() => void timeline.fetchNextPage()} 50 - className="flex-1" 51 - data={timeline.data.pages.flatMap((page) => page.feed)} 52 - estimatedItemSize={107} 53 - renderItem={({ item }) => <Post item={item} />} 54 - /> 55 - ); 56 - } 57 - } 58 - 59 - export default function TimelinePage() { 60 - return ( 61 - <> 62 - {/* <Tabs.Screen /> */} 63 - <Timeline /> 64 - </> 65 - ); 66 - } 67 - 68 - const Post = ({ item }: { item: FeedViewPost }) => { 69 - return ( 70 - <View 71 - className="gap- border border-b border-neutral-200 bg-white p-4" 72 - // onLayout={(x) => console.log(x.nativeEvent.layout)} 73 - > 74 - {void console.log(item.post.embed)} 75 - <Text className="text-base"> 76 - {item.post.author.displayName}{" "} 77 - <Text className="text-neutral-400">@{item.post.author.handle}</Text> 78 - </Text> 79 - {/* text content */} 80 - <Text className="text-base"> 81 - {(item.post.record as AppBskyFeedPost.Record).text} 82 - </Text> 83 - <View className="flex-row justify-between pt-2"> 84 - <View className="flex-row items-center gap-2"> 85 - <MessageSquare size={16} color="#1C1C1E" /> 86 - <Text>{item.post.replyCount}</Text> 87 - </View> 88 - <View className="flex-row items-center gap-2"> 89 - <Repeat 90 - size={16} 91 - color={item.post.viewer?.repost ? "#2563eb" : "#1C1C1E"} 92 - /> 93 - <Text 94 - style={{ 95 - color: item.post.viewer?.repost ? "#2563eb" : "#1C1C1E", 96 - }} 97 - > 98 - {item.post.repostCount} 99 - </Text> 100 - </View> 101 - <View className="flex-row items-center gap-2"> 102 - <Heart 103 - size={16} 104 - fill={item.post.viewer?.like ? "#dc2626" : "transparent"} 105 - color={item.post.viewer?.like ? "#dc2626" : "#1C1C1E"} 106 - /> 107 - <Text 108 - style={{ 109 - color: item.post.viewer?.like ? "#dc2626" : "#1C1C1E", 110 - }} 111 - > 112 - {item.post.likeCount} 113 - </Text> 114 - </View> 115 - <View className="w-8" /> 116 - </View> 117 - </View> 118 - ); 119 - };
+275
apps/expo/src/app/(app)/timeline.tsx
··· 1 + import { useState } from "react"; 2 + import { 3 + ActivityIndicator, 4 + Image, 5 + Linking, 6 + Text, 7 + TouchableOpacity, 8 + View, 9 + } from "react-native"; 10 + import { Tabs } from "expo-router"; 11 + import { type AppBskyFeedPost } from "@atproto/api"; 12 + import { type FeedViewPost } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 13 + import { FlashList } from "@shopify/flash-list"; 14 + import { useInfiniteQuery, useMutation } from "@tanstack/react-query"; 15 + import { Cloudy, Heart, MessageSquare, Repeat } from "lucide-react-native"; 16 + 17 + import { Button } from "../../components/button"; 18 + import { useAuthedAgent } from "../../lib/agent"; 19 + 20 + function Timeline() { 21 + const agent = useAuthedAgent(); 22 + const timeline = useInfiniteQuery({ 23 + queryKey: ["timeline"], 24 + queryFn: async ({ pageParam }) => { 25 + const timeline = await agent.getTimeline({ 26 + limit: 5, 27 + cursor: pageParam as string | undefined, 28 + }); 29 + return timeline.data; 30 + }, 31 + getNextPageParam: (lastPage) => lastPage.cursor, 32 + }); 33 + 34 + switch (timeline.status) { 35 + case "loading": 36 + return ( 37 + <View className="flex-1 items-center justify-center"> 38 + <ActivityIndicator size="large" /> 39 + </View> 40 + ); 41 + 42 + case "error": 43 + return ( 44 + <View className="flex-1 items-center justify-center p-4"> 45 + <Text className="text-center text-xl"> 46 + {(timeline.error as Error).message || "An error occurred"} 47 + </Text> 48 + <Button variant="outline" onPress={() => void timeline.refetch()}> 49 + Retry 50 + </Button> 51 + </View> 52 + ); 53 + 54 + case "success": 55 + return ( 56 + <FlashList 57 + onRefresh={() => { 58 + if (!timeline.isRefetching) void timeline.refetch(); 59 + }} 60 + refreshing={timeline.isRefetching} 61 + onEndReachedThreshold={0.5} 62 + onEndReached={() => void timeline.fetchNextPage()} 63 + className="flex-1" 64 + data={timeline.data.pages.flatMap((page) => page.feed)} 65 + estimatedItemSize={111} 66 + renderItem={({ item }) => <Post item={item} />} 67 + /> 68 + ); 69 + } 70 + } 71 + 72 + export default function TimelinePage() { 73 + return ( 74 + <> 75 + <Tabs.Screen 76 + options={{ 77 + tabBarButton: () => ( 78 + <View className="flex-1 items-center justify-center"> 79 + <Cloudy color="#5e5e5e" /> 80 + </View> 81 + ), 82 + }} 83 + /> 84 + <Timeline /> 85 + </> 86 + ); 87 + } 88 + 89 + const Post = ({ item }: { item: FeedViewPost }) => { 90 + const agent = useAuthedAgent(); 91 + 92 + const [liked, setLiked] = useState(!!item.post.viewer?.like); 93 + const [likeUri, setLikeUri] = useState(item.post.viewer?.like); 94 + const [reposted, setReposted] = useState(!!item.post.viewer?.repost); 95 + const [repostUri, setRepostUri] = useState(item.post.viewer?.repost); 96 + 97 + const toggleLike = useMutation({ 98 + mutationKey: ["like", item.post.uri], 99 + mutationFn: async () => { 100 + if (!likeUri) { 101 + try { 102 + setLiked(true); 103 + const like = await agent.like(item.post.uri, item.post.cid); 104 + setLikeUri(like.uri); 105 + } catch (err) { 106 + setLiked(false); 107 + console.log(err); 108 + } 109 + } else { 110 + try { 111 + setLiked(false); 112 + await agent.deleteLike(likeUri); 113 + setLikeUri(undefined); 114 + } catch (err) { 115 + setLiked(true); 116 + console.log(err); 117 + } 118 + } 119 + }, 120 + }); 121 + 122 + const toggleRepost = useMutation({ 123 + mutationKey: ["repost", item.post.uri], 124 + mutationFn: async () => { 125 + if (!repostUri) { 126 + try { 127 + setReposted(true); 128 + const repost = await agent.repost(item.post.uri, item.post.cid); 129 + setRepostUri(repost.uri); 130 + } catch (err) { 131 + setReposted(false); 132 + console.log(err); 133 + } 134 + } else { 135 + try { 136 + setReposted(false); 137 + await agent.deleteRepost(repostUri); 138 + setRepostUri(undefined); 139 + } catch (err) { 140 + setReposted(true); 141 + console.log(err); 142 + } 143 + } 144 + }, 145 + }); 146 + 147 + return ( 148 + <View 149 + className="gap-2 border border-b border-neutral-200 bg-white px-4 pb-5 pt-1" 150 + // onLayout={(x) => console.log(x.nativeEvent.layout)} 151 + > 152 + <View className="flex-row items-center"> 153 + {item.post.author.avatar && ( 154 + <Image 155 + source={{ uri: item.post.author.avatar }} 156 + alt={item.post.author.handle} 157 + className="mr-2 h-6 w-6 rounded-full" 158 + /> 159 + )} 160 + <Text className="w-full text-base" numberOfLines={1}> 161 + {item.post.author.displayName}{" "} 162 + <Text className="text-neutral-400">@{item.post.author.handle}</Text> 163 + </Text> 164 + </View> 165 + {/* text content */} 166 + <Text className="text-base"> 167 + {(item.post.record as AppBskyFeedPost.Record).text} 168 + </Text> 169 + {/* embeds */} 170 + {item.post.embed && <Embed content={item.post.embed as PostEmbed} />} 171 + {/* actions */} 172 + <View className="flex-row justify-between"> 173 + <TouchableOpacity className="flex-row items-center gap-2"> 174 + <MessageSquare size={16} color="#1C1C1E" /> 175 + <Text>{item.post.replyCount}</Text> 176 + </TouchableOpacity> 177 + <TouchableOpacity 178 + disabled={toggleRepost.isLoading} 179 + onPress={() => toggleRepost.mutate()} 180 + className="flex-row items-center gap-2" 181 + > 182 + <Repeat size={16} color={reposted ? "#2563eb" : "#1C1C1E"} /> 183 + <Text 184 + style={{ 185 + color: reposted ? "#2563eb" : "#1C1C1E", 186 + }} 187 + > 188 + {(item.post.repostCount ?? 0) + 189 + (reposted && repostUri !== item.post.viewer?.repost ? 1 : 0)} 190 + </Text> 191 + </TouchableOpacity> 192 + <TouchableOpacity 193 + disabled={toggleLike.isLoading} 194 + onPress={() => toggleLike.mutate()} 195 + className="flex-row items-center gap-2" 196 + > 197 + <Heart 198 + size={16} 199 + fill={liked ? "#dc2626" : "transparent"} 200 + color={liked ? "#dc2626" : "#1C1C1E"} 201 + /> 202 + <Text 203 + style={{ 204 + color: liked ? "#dc2626" : "#1C1C1E", 205 + }} 206 + > 207 + {(item.post.likeCount ?? 0) + 208 + (liked && likeUri !== item.post.viewer?.like ? 1 : 0)} 209 + </Text> 210 + </TouchableOpacity> 211 + <View className="w-8" /> 212 + </View> 213 + </View> 214 + ); 215 + }; 216 + 217 + type PostEmbed = 218 + | { 219 + $type: "app.bsky.embed.images#view"; 220 + images: { 221 + alt: string; 222 + fullsize: string; 223 + thumb: string; 224 + }[]; 225 + } 226 + | { 227 + $type: "app.bsky.embed.external#view"; 228 + external: { 229 + description: string; 230 + thumb: string; 231 + title: string; 232 + uri: string; 233 + }; 234 + }; 235 + 236 + const Embed = ({ content }: { content: PostEmbed }) => { 237 + switch (content.$type) { 238 + case "app.bsky.embed.images#view": 239 + switch (content.images.length) { 240 + case 0: 241 + return null; 242 + case 1: 243 + default: 244 + const image = content.images[0]!; 245 + return ( 246 + <Image 247 + source={{ uri: image.thumb }} 248 + alt={image.alt} 249 + className="my-1.5 aspect-video w-full rounded" 250 + /> 251 + ); 252 + } 253 + case "app.bsky.embed.external#view": 254 + return ( 255 + <TouchableOpacity 256 + onPress={() => void Linking.openURL(content.external.uri)} 257 + className="my-1.5 rounded border p-2" 258 + > 259 + <Text className="text-base" numberOfLines={2}> 260 + {content.external.title} 261 + </Text> 262 + <Text className="text-sm text-neutral-400" numberOfLines={1}> 263 + {content.external.uri} 264 + </Text> 265 + </TouchableOpacity> 266 + ); 267 + default: 268 + console.info("Unsupported embed type", content); 269 + return ( 270 + <View className="my-1.5 rounded bg-neutral-100 p-2"> 271 + <Text className="text-center">Unsupported embed type</Text> 272 + </View> 273 + ); 274 + } 275 + };
+1 -1
apps/expo/src/app/_layout.tsx
··· 99 99 router.replace("/login"); 100 100 } else if (did && (inAuthGroup || atRoot)) { 101 101 // Redirect away from the sign-in page. 102 - router.replace("/app"); 102 + router.replace("/timeline"); 103 103 } 104 104 }, [did, segments, router, loading]); 105 105
+1
packages/config/eslint/index.js
··· 22 22 "error", 23 23 { prefer: "type-imports", fixStyle: "inline-type-imports" }, 24 24 ], 25 + "@typescript-eslint/no-non-null-assertion": "off", 25 26 }, 26 27 ignorePatterns: ["**/*.config.js", "**/*.config.cjs", "packages/config/**"], 27 28 reportUnusedDisableDirectives: true,