this repo has no description
0
fork

Configure Feed

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

add image viewer

+282 -65
+1 -1
apps/expo/app.config.ts
··· 4 4 name: "Graysky", 5 5 slug: "graysky", 6 6 scheme: "graysky", 7 - version: "0.0.2", 7 + version: "0.0.3", 8 8 owner: "mozzius", 9 9 orientation: "portrait", 10 10 icon: "./assets/icon.png",
+5 -1
apps/expo/babel.config.js
··· 7 7 process.env.EXPO_ROUTER_APP_ROOT = "../../apps/expo/src/app"; 8 8 9 9 return { 10 - plugins: ["nativewind/babel", require.resolve("expo-router/babel")], 10 + plugins: [ 11 + "nativewind/babel", 12 + require.resolve("expo-router/babel"), 13 + "react-native-reanimated/plugin", 14 + ], 11 15 presets: ["babel-preset-expo"], 12 16 }; 13 17 };
+2 -1
apps/expo/package.json
··· 1 1 { 2 2 "name": "@graysky/app", 3 - "version": "0.0.2", 3 + "version": "0.0.3", 4 4 "main": "index.tsx", 5 5 "scripts": { 6 6 "clean": "git clean -xdf .expo .turbo node_modules", ··· 37 37 "react": "18.2.0", 38 38 "react-dom": "18.2.0", 39 39 "react-native": "0.71.7", 40 + "react-native-reanimated": "^3.1.0", 40 41 "react-native-safe-area-context": "4.5.1", 41 42 "react-native-screens": "~3.20.0", 42 43 "react-native-svg": "^13.9.0",
+6 -1
apps/expo/src/app/(tabs)/notifications.tsx
··· 346 346 <RichText value={post.data.post.record.text} size="sm" /> 347 347 </Text> 348 348 {post.data.post.embed && ( 349 - <Embed content={post.data.post.embed} truncate depth={1} /> 349 + <Embed 350 + uri={post.data.post.uri} 351 + content={post.data.post.embed} 352 + truncate 353 + depth={1} 354 + /> 350 355 )} 351 356 </View> 352 357 );
+76
apps/expo/src/app/images/[post].tsx
··· 1 + import { Text, TextInput, TouchableOpacity, View } from "react-native"; 2 + import { SafeAreaView } from "react-native-safe-area-context"; 3 + import { Stack, useLocalSearchParams, useRouter } from "expo-router"; 4 + import { AppBskyEmbedImages, AppBskyFeedDefs } from "@atproto/api"; 5 + import { useQuery } from "@tanstack/react-query"; 6 + import { X } from "lucide-react-native"; 7 + 8 + import { ImageViewer } from "../../components/image-viewer"; 9 + import { useAuthedAgent } from "../../lib/agent"; 10 + import { assert } from "../../lib/utils/assert"; 11 + 12 + export default function ImageModal() { 13 + const agent = useAuthedAgent(); 14 + const router = useRouter(); 15 + const { post, initial } = useLocalSearchParams() as { 16 + post: string; 17 + initial?: string; 18 + }; 19 + 20 + const uri = decodeURIComponent(post); 21 + 22 + const images = useQuery({ 23 + queryKey: ["images", uri], 24 + queryFn: async () => { 25 + const record = await agent.getPostThread({ 26 + uri, 27 + depth: 0, 28 + }); 29 + 30 + if (!AppBskyFeedDefs.isThreadViewPost(record.data.thread)) { 31 + throw new Error("Invalid thread post"); 32 + } 33 + assert(AppBskyFeedDefs.validateThreadViewPost(record.data.thread)); 34 + 35 + if (!AppBskyEmbedImages.isView(record.data.thread.post.embed)) { 36 + throw new Error("Invalid embed"); 37 + } 38 + assert(AppBskyEmbedImages.validateView(record.data.thread.post.embed)); 39 + 40 + return record.data.thread.post.embed.images; 41 + }, 42 + retry: false, 43 + refetchOnMount: false, 44 + refetchOnWindowFocus: false, 45 + }); 46 + 47 + if (images.isError) { 48 + console.warn(images.error); 49 + router.back(); 50 + } 51 + 52 + return ( 53 + <SafeAreaView className="relative flex-1 bg-black"> 54 + <Stack.Screen 55 + options={{ 56 + animation: "fade_from_bottom", 57 + customAnimationOnGesture: true, 58 + headerShown: false, 59 + }} 60 + /> 61 + <View className="flex-1"> 62 + <TouchableOpacity 63 + onPress={() => router.back()} 64 + className="absolute right-5 top-5 z-10 h-10 w-10 items-center justify-center rounded-full bg-black/40" 65 + > 66 + <X color="#ffffff" /> 67 + </TouchableOpacity> 68 + <ImageViewer 69 + images={images.data ?? []} 70 + onClose={() => router.back()} 71 + initialIndex={Number(initial) || 0} 72 + /> 73 + </View> 74 + </SafeAreaView> 75 + ); 76 + }
+104 -57
apps/expo/src/components/embed.tsx
··· 3 3 Image, 4 4 ImageBackground, 5 5 Linking, 6 + Pressable, 6 7 Text, 7 8 TouchableOpacity, 8 9 View, ··· 18 19 type AppBskyFeedDefs, 19 20 } from "@atproto/api"; 20 21 22 + import { queryClient } from "../lib/query-client"; 21 23 import { assert } from "../lib/utils/assert"; 24 + import { cx } from "../lib/utils/cx"; 22 25 23 26 function useImageAspectRatio(imageUrl: string) { 24 27 const [aspectRatio, setAspectRatio] = useState(1); ··· 44 47 } 45 48 46 49 interface Props { 50 + uri: string; 47 51 content: AppBskyFeedDefs.FeedViewPost["post"]["embed"]; 48 52 truncate?: boolean; 49 53 depth?: number; 50 54 } 51 55 52 - export const Embed = ({ content, truncate = true, depth = 0 }: Props) => { 56 + export const Embed = ({ uri, content, truncate = true, depth = 0 }: Props) => { 53 57 if (!content) return null; 54 58 try { 55 59 // Case 1: Image 56 60 if (AppBskyEmbedImages.isView(content)) { 57 61 assert(AppBskyEmbedImages.validateView(content)); 58 - return <ImageEmbed content={content} depth={depth} />; 62 + return <ImageEmbed uri={uri} content={content} depth={depth} />; 59 63 } 60 64 61 65 // Case 2: External link ··· 64 68 return ( 65 69 <TouchableOpacity 66 70 onPress={() => void Linking.openURL(content.external.uri)} 67 - className="my-1.5 rounded border border-neutral-300 p-2" 71 + className="my-1.5 overflow-hidden rounded border border-neutral-300" 68 72 > 69 - <Text className="text-base font-semibold" numberOfLines={2}> 70 - {content.external.title || content.external.uri} 71 - </Text> 72 - 73 - <Text className="text-sm text-neutral-400" numberOfLines={1}> 74 - {content.external.uri} 75 - </Text> 73 + {content.external.thumb && ( 74 + <Image 75 + source={{ uri: content.external.thumb }} 76 + className="h-32 w-full object-cover" 77 + /> 78 + )} 79 + <View 80 + className={cx( 81 + "w-full p-2", 82 + content.external.thumb && "border-t border-neutral-300", 83 + )} 84 + > 85 + <Text className="text-base font-semibold" numberOfLines={2}> 86 + {content.external.title || content.external.uri} 87 + </Text> 88 + <Text className="text-sm text-neutral-400" numberOfLines={1}> 89 + {content.external.uri} 90 + </Text> 91 + <Text className="mt-1 text-sm leading-5" numberOfLines={2}> 92 + {content.external.description} 93 + </Text> 94 + </View> 76 95 </TouchableOpacity> 77 96 ); 78 97 } ··· 104 123 105 124 return ( 106 125 <> 107 - {media && <Embed content={media} depth={depth + 1} />} 126 + {media && <Embed uri={uri} content={media} depth={depth + 1} />} 108 127 <PostEmbed author={record.author} uri={record.uri}> 109 128 <Text 110 129 className="mt-1 text-base leading-5" ··· 114 133 </Text> 115 134 {/* in what case will there be more than one? in what order do we show them? */} 116 135 {record.embeds && ( 117 - <Embed content={record.embeds[0]} depth={depth + 1} /> 136 + <Embed 137 + uri={record.uri} 138 + content={record.embeds[0]} 139 + depth={depth + 1} 140 + /> 118 141 )} 119 142 </PostEmbed> 120 143 </> ··· 133 156 }; 134 157 135 158 const ImageEmbed = ({ 159 + uri, 136 160 content, 137 161 depth, 138 162 }: { 163 + uri: string; 139 164 content: AppBskyEmbedImages.View; 140 165 depth: number; 141 166 }) => { 142 167 const aspectRatio = useImageAspectRatio(content.images[0]!.thumb); 168 + const href = `/images/${encodeURIComponent(uri)}`; 169 + 170 + useEffect(() => { 171 + queryClient.setQueryData(["images", uri], content.images); 172 + }, [content.images]); 173 + 143 174 switch (content.images.length) { 144 175 case 0: 145 176 return null; 146 177 case 1: 147 178 const image = content.images[0]!; 148 179 return ( 149 - <Image 150 - source={{ uri: image.thumb }} 151 - alt={image.alt} 152 - className="mt-1.5 w-full rounded" 153 - style={{ 154 - aspectRatio: 155 - depth > 0 156 - ? Math.max(aspectRatio, 1.5) 157 - : Math.max(aspectRatio, 0.5), 158 - }} 159 - /> 180 + <Link href={href} asChild> 181 + <Pressable> 182 + <Image 183 + source={{ uri: image.thumb }} 184 + alt={image.alt} 185 + className="mt-1.5 w-full rounded" 186 + style={{ 187 + aspectRatio: 188 + depth > 0 189 + ? Math.max(aspectRatio, 1.5) 190 + : Math.max(aspectRatio, 0.5), 191 + }} 192 + /> 193 + </Pressable> 194 + </Link> 160 195 ); 161 196 case 2: 162 197 return ( 163 198 <View className="mt-1.5 flex flex-row justify-between overflow-hidden rounded"> 164 - {content.images.map((image) => ( 165 - <Image 166 - key={image.fullsize} 167 - source={{ uri: image.thumb }} 168 - alt={image.alt} 169 - className="aspect-square w-[49%]" 170 - /> 199 + {content.images.map((image, i) => ( 200 + <Link href={`${href}?initial=${i}`} asChild key={image.fullsize}> 201 + <Pressable className="w-[49%]"> 202 + <Image 203 + source={{ uri: image.thumb }} 204 + alt={image.alt} 205 + className="aspect-square" 206 + /> 207 + </Pressable> 208 + </Link> 171 209 ))} 172 210 </View> 173 211 ); 174 212 case 3: 175 213 return ( 176 214 <View className="mt-1.5 flex flex-row justify-between overflow-hidden rounded"> 177 - {content.images.map((image) => ( 178 - <Image 179 - key={image.fullsize} 180 - source={{ uri: image.thumb }} 181 - alt={image.alt} 182 - className="aspect-square w-[32%]" 183 - /> 215 + {content.images.map((image, i) => ( 216 + <Link href={`${href}?initial=${i}`} asChild key={image.fullsize}> 217 + <Pressable className="w-[32%]"> 218 + <Image 219 + source={{ uri: image.thumb }} 220 + alt={image.alt} 221 + className="aspect-square" 222 + /> 223 + </Pressable> 224 + </Link> 184 225 ))} 185 226 </View> 186 227 ); 187 228 default: 188 229 return ( 189 230 <View className="my-1.5 flex flex-row justify-between overflow-hidden rounded"> 190 - {content.images.slice(0, 2).map((image) => ( 191 - <Image 192 - key={image.fullsize} 193 - source={{ uri: image.thumb }} 194 - alt={image.alt} 195 - className="aspect-square w-[32%]" 196 - /> 231 + {content.images.slice(0, 2).map((image, i) => ( 232 + <Link href={`${href}?initial=${i}`} asChild key={image.fullsize}> 233 + <Pressable className="w-[32%]"> 234 + <Image 235 + key={image.fullsize} 236 + source={{ uri: image.thumb }} 237 + alt={image.alt} 238 + className="aspect-square" 239 + /> 240 + </Pressable> 241 + </Link> 197 242 ))} 198 - <View className="aspect-square w-[32%]"> 199 - <ImageBackground 200 - source={{ uri: content.images[2]!.thumb }} 201 - alt={content.images[2]!.alt} 202 - resizeMode="cover" 203 - > 204 - <View className="h-full w-full items-center justify-center bg-black/60 p-1"> 205 - <Text className="text-center text-base font-bold text-white"> 206 - +{content.images.length - 2} 207 - </Text> 208 - </View> 209 - </ImageBackground> 210 - </View> 243 + <Link href={href} asChild> 244 + <Pressable className="aspect-square w-[32%]"> 245 + <ImageBackground 246 + source={{ uri: content.images[2]!.thumb }} 247 + alt={content.images[2]!.alt} 248 + resizeMode="cover" 249 + > 250 + <View className="h-full w-full items-center justify-center bg-black/60 p-1"> 251 + <Text className="text-center text-base font-bold text-white"> 252 + +{content.images.length - 2} 253 + </Text> 254 + </View> 255 + </ImageBackground> 256 + </Pressable> 257 + </Link> 211 258 </View> 212 259 ); 213 260 }
+3 -1
apps/expo/src/components/feed-post.tsx
··· 119 119 </Pressable> 120 120 </Link> 121 121 {/* embeds */} 122 - {item.post.embed && <Embed content={item.post.embed} />} 122 + {item.post.embed && ( 123 + <Embed uri={item.post.uri} content={item.post.embed} /> 124 + )} 123 125 {/* actions */} 124 126 <View className="mt-2 flex-row justify-between"> 125 127 <TouchableOpacity className="flex-row items-center gap-2">
+34
apps/expo/src/components/image-viewer.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { Dimensions, Image, ScrollView, View } from "react-native"; 3 + import { AppBskyEmbedImages } from "@atproto/api"; 4 + 5 + const { width, height } = Dimensions.get("screen"); 6 + 7 + interface Props { 8 + images: AppBskyEmbedImages.ViewImage[]; 9 + initialIndex?: number; 10 + onClose: () => void; 11 + } 12 + 13 + export const ImageViewer = ({ images, initialIndex = 0, onClose }: Props) => { 14 + return ( 15 + <ScrollView 16 + horizontal 17 + snapToInterval={width} 18 + decelerationRate="fast" 19 + contentOffset={{ x: initialIndex * width, y: 0 }} 20 + > 21 + {images.map((image, i) => { 22 + return ( 23 + <View key={i} className="w-screen flex-1"> 24 + <Image 25 + source={{ uri: image.fullsize }} 26 + className="flex-1" 27 + resizeMode="contain" 28 + /> 29 + </View> 30 + ); 31 + })} 32 + </ScrollView> 33 + ); 34 + };
+3 -1
apps/expo/src/components/post.tsx
··· 70 70 {/* text content */} 71 71 <RichText value={post.record.text} size="lg" /> 72 72 {/* embeds */} 73 - {post.embed && <Embed content={post.embed} truncate={false} />} 73 + {post.embed && ( 74 + <Embed uri={post.uri} content={post.embed} truncate={false} /> 75 + )} 74 76 {/* actions */} 75 77 <View className="mt-4 flex-row justify-between"> 76 78 <TouchableOpacity className="flex-row items-center gap-2">
+48 -2
pnpm-lock.yaml
··· 75 75 version: 4.0.1(expo@48.0.11) 76 76 expo-router: 77 77 specifier: ^1.5.3 78 - version: 1.5.3(expo-constants@14.2.1)(expo-linking@4.0.1)(expo-modules-autolinking@1.2.0)(expo-status-bar@1.4.4)(expo@48.0.11)(metro@0.76.2)(react-dom@18.2.0)(react-native-gesture-handler@2.9.0)(react-native-safe-area-context@4.5.1)(react-native-screens@3.20.0)(react-native@0.71.7)(react@18.2.0) 78 + version: 1.5.3(expo-constants@14.2.1)(expo-linking@4.0.1)(expo-modules-autolinking@1.2.0)(expo-status-bar@1.4.4)(expo@48.0.11)(metro@0.76.2)(react-dom@18.2.0)(react-native-gesture-handler@2.9.0)(react-native-reanimated@3.1.0)(react-native-safe-area-context@4.5.1)(react-native-screens@3.20.0)(react-native@0.71.7)(react@18.2.0) 79 79 expo-splash-screen: 80 80 specifier: ~0.18.1 81 81 version: 0.18.1(expo-modules-autolinking@1.2.0)(expo@48.0.11) ··· 106 106 react-native: 107 107 specifier: 0.71.7 108 108 version: 0.71.7(@babel/core@7.21.4)(@babel/preset-env@7.21.4)(react@18.2.0) 109 + react-native-reanimated: 110 + specifier: ^3.1.0 111 + version: 3.1.0(@babel/core@7.21.4)(@babel/plugin-proposal-nullish-coalescing-operator@7.18.6)(@babel/plugin-proposal-optional-chaining@7.21.0)(@babel/plugin-transform-arrow-functions@7.20.7)(@babel/plugin-transform-shorthand-properties@7.18.6)(@babel/plugin-transform-template-literals@7.18.9)(react-native@0.71.7)(react@18.2.0) 109 112 react-native-safe-area-context: 110 113 specifier: 4.5.1 111 114 version: 4.5.1(react-native@0.71.7)(react@18.2.0) ··· 1260 1263 '@babel/core': 7.21.4 1261 1264 '@babel/helper-plugin-utils': 7.20.2 1262 1265 1266 + /@babel/plugin-transform-object-assign@7.18.6(@babel/core@7.21.4): 1267 + resolution: {integrity: sha512-mQisZ3JfqWh2gVXvfqYCAAyRs6+7oev+myBsTwW5RnPhYXOTuCEw2oe3YgxlXMViXUS53lG8koulI7mJ+8JE+A==} 1268 + engines: {node: '>=6.9.0'} 1269 + peerDependencies: 1270 + '@babel/core': ^7.0.0-0 1271 + dependencies: 1272 + '@babel/core': 7.21.4 1273 + '@babel/helper-plugin-utils': 7.20.2 1274 + dev: false 1275 + 1263 1276 /@babel/plugin-transform-object-super@7.18.6(@babel/core@7.21.4): 1264 1277 resolution: {integrity: sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==} 1265 1278 engines: {node: '>=6.9.0'} ··· 4154 4167 4155 4168 /convert-source-map@1.9.0: 4156 4169 resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} 4170 + 4171 + /convert-source-map@2.0.0: 4172 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 4173 + dev: false 4157 4174 4158 4175 /copy-anything@3.0.3: 4159 4176 resolution: {integrity: sha512-fpW2W/BqEzqPp29QS+MwwfisHCQZtiduTe/m8idFo0xbti9fIZ2WVhAsCv4ggFVH3AgCkVdpoOCtQC6gBrdhjw==} ··· 5239 5256 invariant: 2.2.4 5240 5257 dev: false 5241 5258 5242 - /expo-router@1.5.3(expo-constants@14.2.1)(expo-linking@4.0.1)(expo-modules-autolinking@1.2.0)(expo-status-bar@1.4.4)(expo@48.0.11)(metro@0.76.2)(react-dom@18.2.0)(react-native-gesture-handler@2.9.0)(react-native-safe-area-context@4.5.1)(react-native-screens@3.20.0)(react-native@0.71.7)(react@18.2.0): 5259 + /expo-router@1.5.3(expo-constants@14.2.1)(expo-linking@4.0.1)(expo-modules-autolinking@1.2.0)(expo-status-bar@1.4.4)(expo@48.0.11)(metro@0.76.2)(react-dom@18.2.0)(react-native-gesture-handler@2.9.0)(react-native-reanimated@3.1.0)(react-native-safe-area-context@4.5.1)(react-native-screens@3.20.0)(react-native@0.71.7)(react@18.2.0): 5243 5260 resolution: {integrity: sha512-rZEoRpXjXpfcx549/MI7YRitaBGFOHpIGLO+cb18ecsShl3PzGPIDaBGMnTo0m1h7ip0sAIQg1EFrSAtM4LXLA==} 5244 5261 peerDependencies: 5245 5262 '@react-navigation/drawer': ^6.5.8 ··· 5273 5290 query-string: 7.1.3 5274 5291 react-helmet-async: 1.3.0(react-dom@18.2.0)(react@18.2.0) 5275 5292 react-native-gesture-handler: 2.9.0(react-native@0.71.7)(react@18.2.0) 5293 + react-native-reanimated: 3.1.0(@babel/core@7.21.4)(@babel/plugin-proposal-nullish-coalescing-operator@7.18.6)(@babel/plugin-proposal-optional-chaining@7.21.0)(@babel/plugin-transform-arrow-functions@7.20.7)(@babel/plugin-transform-shorthand-properties@7.18.6)(@babel/plugin-transform-template-literals@7.18.9)(react-native@0.71.7)(react@18.2.0) 5276 5294 react-native-safe-area-context: 4.5.1(react-native@0.71.7)(react@18.2.0) 5277 5295 react-native-screens: 3.20.0(react-native@0.71.7)(react@18.2.0) 5278 5296 url: 0.11.0 ··· 8914 8932 8915 8933 /react-native-gradle-plugin@0.71.17: 8916 8934 resolution: {integrity: sha512-OXXYgpISEqERwjSlaCiaQY6cTY5CH6j73gdkWpK0hedxtiWMWgH+i5TOi4hIGYitm9kQBeyDu+wim9fA8ROFJA==} 8935 + dev: false 8936 + 8937 + /react-native-reanimated@3.1.0(@babel/core@7.21.4)(@babel/plugin-proposal-nullish-coalescing-operator@7.18.6)(@babel/plugin-proposal-optional-chaining@7.21.0)(@babel/plugin-transform-arrow-functions@7.20.7)(@babel/plugin-transform-shorthand-properties@7.18.6)(@babel/plugin-transform-template-literals@7.18.9)(react-native@0.71.7)(react@18.2.0): 8938 + resolution: {integrity: sha512-8YJR7yHnrqK6yKWzkGLVEawi1WZqJ9bGIehKEnE8zG58yLrSwUZe1T220XTbftpkA3r37Sy0kJJ/HOOiaIU+HQ==} 8939 + peerDependencies: 8940 + '@babel/core': ^7.0.0-0 8941 + '@babel/plugin-proposal-nullish-coalescing-operator': ^7.0.0-0 8942 + '@babel/plugin-proposal-optional-chaining': ^7.0.0-0 8943 + '@babel/plugin-transform-arrow-functions': ^7.0.0-0 8944 + '@babel/plugin-transform-shorthand-properties': ^7.0.0-0 8945 + '@babel/plugin-transform-template-literals': ^7.0.0-0 8946 + react: '*' 8947 + react-native: '*' 8948 + dependencies: 8949 + '@babel/core': 7.21.4 8950 + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.21.4) 8951 + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.21.4) 8952 + '@babel/plugin-transform-arrow-functions': 7.20.7(@babel/core@7.21.4) 8953 + '@babel/plugin-transform-object-assign': 7.18.6(@babel/core@7.21.4) 8954 + '@babel/plugin-transform-shorthand-properties': 7.18.6(@babel/core@7.21.4) 8955 + '@babel/plugin-transform-template-literals': 7.18.9(@babel/core@7.21.4) 8956 + '@babel/preset-typescript': 7.21.4(@babel/core@7.21.4) 8957 + convert-source-map: 2.0.0 8958 + invariant: 2.2.4 8959 + react: 18.2.0 8960 + react-native: 0.71.7(@babel/core@7.21.4)(@babel/preset-env@7.21.4)(react@18.2.0) 8961 + transitivePeerDependencies: 8962 + - supports-color 8917 8963 dev: false 8918 8964 8919 8965 /react-native-safe-area-context@4.5.1(react-native@0.71.7)(react@18.2.0):