Hopefully feature-complete Android Bluesky client written in Expo
atproto bluesky

Finally get profile working how I want

Changed files
+176 -143
src
app
(authenticated)
profile
+3
bun.lock
··· 11 "@atproto/oauth-client-expo": "^0.0.5", 12 "@expo/vector-icons": "^15.0.3", 13 "@formatjs/intl-segmenter": "^12.0.5", 14 "@pchmn/expo-material3-theme": "^1.3.2", 15 "@react-navigation/bottom-tabs": "^7.4.0", 16 "@react-navigation/elements": "^2.6.3", ··· 432 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 433 434 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 435 436 "@material/material-color-utilities": ["@material/material-color-utilities@0.2.7", "", {}, "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ=="], 437
··· 11 "@atproto/oauth-client-expo": "^0.0.5", 12 "@expo/vector-icons": "^15.0.3", 13 "@formatjs/intl-segmenter": "^12.0.5", 14 + "@legendapp/list": "^2.0.19", 15 "@pchmn/expo-material3-theme": "^1.3.2", 16 "@react-navigation/bottom-tabs": "^7.4.0", 17 "@react-navigation/elements": "^2.6.3", ··· 433 "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 434 435 "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 436 + 437 + "@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="], 438 439 "@material/material-color-utilities": ["@material/material-color-utilities@0.2.7", "", {}, "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ=="], 440
+1
package.json
··· 17 "@atproto/oauth-client-expo": "^0.0.5", 18 "@expo/vector-icons": "^15.0.3", 19 "@formatjs/intl-segmenter": "^12.0.5", 20 "@pchmn/expo-material3-theme": "^1.3.2", 21 "@react-navigation/bottom-tabs": "^7.4.0", 22 "@react-navigation/elements": "^2.6.3",
··· 17 "@atproto/oauth-client-expo": "^0.0.5", 18 "@expo/vector-icons": "^15.0.3", 19 "@formatjs/intl-segmenter": "^12.0.5", 20 + "@legendapp/list": "^2.0.19", 21 "@pchmn/expo-material3-theme": "^1.3.2", 22 "@react-navigation/bottom-tabs": "^7.4.0", 23 "@react-navigation/elements": "^2.6.3",
+172 -143
src/app/(authenticated)/profile/[userId].tsx
··· 8 import { ActorIdentifier } from "@atcute/lexicons"; 9 import { Image } from "expo-image"; 10 import { useSafeAreaInsets } from "react-native-safe-area-context"; 11 - import { FlashList, FlashListRef } from "@shopify/flash-list"; 12 import Post from "@/src/components/Post"; 13 import { AppBskyLabelerDefs } from "@atcute/bluesky"; 14 - import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useScrollOffset } from "react-native-reanimated"; 15 import { useState } from "react"; 16 17 type AuthorFeedFilters = ··· 29 const insets = useSafeAreaInsets(); 30 const router = useRouter(); 31 32 - const flashListRef = useAnimatedRef<FlashListRef<any>>(); 33 - const scrollOffset = useScrollOffset(flashListRef); 34 35 const [feedFilter, setFeedFilter] = useState<AuthorFeedFilters>("posts_and_author_threads"); 36 ··· 154 pointerEvents: scrollOffset.value >= 150 ? "auto" : "none", 155 })); 156 157 return ( 158 <View style={{ backgroundColor: theme[colorScheme!].background, height: "100%", maxHeight: "100%" }}> 159 <Animated.View 160 style={[ 161 { 162 backgroundColor: theme[colorScheme!].elevation.level2, ··· 196 </Pressable> 197 <Pressable 198 onPress={() => { 199 - flashListRef.current!.scrollToTop({ animated: true }); 200 }} 201 > 202 <Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 14 }}> ··· 208 </Pressable> 209 </Animated.View> 210 <View style={{ height: "100%" }}> 211 - <FlashList 212 - ref={flashListRef} 213 - scrollEventThrottle={16} 214 - decelerationRate={"normal"} 215 - snapToEnd={false} 216 - snapToStart={false} 217 - pagingEnabled={false} 218 - data={["a", ...(authorFeedQuery.data?.pages ?? []).flatMap((val) => val.feed)]} 219 - onEndReached={() => authorFeedQuery.fetchNextPage()} 220 - refreshControl={<RefreshControl progressViewOffset={300} refreshing={authorFeedQuery.isRefetching} />} 221 - ListFooterComponent={<View />} 222 - ListFooterComponentStyle={{ height: 64 }} 223 - onRefresh={() => authorFeedQuery.refetch()} 224 - renderItem={({ item, target }) => { 225 - if (typeof item === "string") { 226 - return ( 227 - <View> 228 - <Pressable 229 style={{ 230 - position: "absolute", 231 - top: insets.top, 232 - zIndex: 1, 233 - backgroundColor: "#00000055", 234 - left: 8, 235 - borderRadius: 32, 236 }} 237 - onPress={() => router.dismiss(1)} 238 > 239 - <Text 240 - style={{ 241 - fontSize: 24, 242 - color: "white", 243 - textAlign: "center", 244 - textAlignVertical: "center", 245 - aspectRatio: 1, 246 - }} 247 - > 248 - 249 - </Text> 250 - </Pressable> 251 - <Image source={profileQuery.data?.banner} style={{ width: "100%", aspectRatio: 3 }} /> 252 - <View style={{ padding: 8 }}> 253 - <Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 24 }}> 254 - {profileQuery.data?.displayName} 255 - </Text> 256 - <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}> 257 - {"@" + profileQuery.data?.handle} 258 - </Text> 259 - {(profileQuery.data?.pronouns || 260 - (labelersQuery.isSuccess && (profileQuery.data?.labels?.length ?? 0) > 0)) && ( 261 - <View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap", marginTop: 4 }}> 262 - {profileQuery.data?.pronouns && ( 263 <Text 264 style={{ 265 color: colorScheme === "light" ? "black" : "white", 266 - backgroundColor: theme[colorScheme!].elevation.level2, 267 - paddingVertical: 4, 268 - paddingHorizontal: 4, 269 - borderRadius: 4, 270 - alignSelf: "flex-start", 271 fontSize: 10, 272 fontWeight: 700, 273 }} 274 > 275 - {profileQuery.data?.pronouns} 276 </Text> 277 - )} 278 - {labelersQuery.isSuccess && 279 - (profileQuery.data?.labels ?? []) 280 - .filter((val) => val.src !== val.uri.slice(5).split("/")[0]) 281 - .map((val) => { 282 - const labeler = labelersQuery.data.views.find( 283 - (labeler) => labeler.creator.did === val.src 284 - ); 285 - const labelDefs = (labeler as AppBskyLabelerDefs.LabelerViewDetailed)?.policies 286 - .labelValueDefinitions; 287 - const label = labelDefs?.find((def) => def.identifier === val.val); 288 289 - if (!label) return null; 290 291 - return ( 292 - <View 293 - key={val.src + val.val} 294 - style={{ 295 - flexDirection: "row", 296 - alignItems: "center", 297 - gap: 2, 298 - backgroundColor: theme[colorScheme!].elevation.level2, 299 - paddingVertical: 4, 300 - paddingHorizontal: 4, 301 - borderRadius: 4, 302 - }} 303 - > 304 - <Image 305 - style={{ width: 12, height: 12, borderRadius: 16 }} 306 - source={labeler?.creator.avatar} 307 - /> 308 - <Text 309 - style={{ 310 - color: colorScheme === "light" ? "black" : "white", 311 - fontSize: 10, 312 - fontWeight: 700, 313 - }} 314 - > 315 - {label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val} 316 - </Text> 317 - </View> 318 - ); 319 - }) 320 - .filter((val) => val)} 321 - </View> 322 - )} 323 - </View> 324 - <View style={{ flexDirection: "row" }}> 325 - <Pressable 326 - style={{ padding: 8 }} 327 - onPress={() => { 328 - setFeedFilter("posts_and_author_threads"); 329 - }} 330 - > 331 - <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Posts</Text> 332 - </Pressable> 333 - 334 - <Pressable 335 - style={{ padding: 8 }} 336 - onPress={() => { 337 - setFeedFilter("posts_with_replies"); 338 - }} 339 - > 340 - <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}> 341 - Replies 342 - </Text> 343 - </Pressable> 344 - 345 - <Pressable 346 - style={{ padding: 8 }} 347 - onPress={() => { 348 - setFeedFilter("posts_with_media"); 349 - }} 350 - > 351 - <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Media</Text> 352 - </Pressable> 353 - </View> 354 - </View> 355 - ); 356 - } else return <Post post={item.post} />; 357 - }} 358 - getItemType={(item) => { 359 - return typeof item === "string" ? "sectionHeader" : "row"; 360 - }} 361 - /> 362 </View> 363 </View> 364 );
··· 8 import { ActorIdentifier } from "@atcute/lexicons"; 9 import { Image } from "expo-image"; 10 import { useSafeAreaInsets } from "react-native-safe-area-context"; 11 + import { LegendListRef } from "@legendapp/list"; 12 + import { AnimatedLegendList } from "@legendapp/list/reanimated"; 13 import Post from "@/src/components/Post"; 14 import { AppBskyLabelerDefs } from "@atcute/bluesky"; 15 + import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; 16 import { useState } from "react"; 17 18 type AuthorFeedFilters = ··· 30 const insets = useSafeAreaInsets(); 31 const router = useRouter(); 32 33 + const listRef = useAnimatedRef<LegendListRef>(); 34 + const scrollOffset = useSharedValue(0); 35 + const headerHeight = useSharedValue(0); 36 + const topBarHeight = useSharedValue(0); 37 38 const [feedFilter, setFeedFilter] = useState<AuthorFeedFilters>("posts_and_author_threads"); 39 ··· 157 pointerEvents: scrollOffset.value >= 150 ? "auto" : "none", 158 })); 159 160 + const headerAnimatedStyle = useAnimatedStyle(() => { 161 + return { 162 + transform: [ 163 + { 164 + translateY: interpolate( 165 + scrollOffset.value, 166 + [headerHeight.value, 0, -headerHeight.value], 167 + [headerHeight.value * 2, 0, headerHeight.value] 168 + ), 169 + }, 170 + ], 171 + }; 172 + }); 173 + 174 + const listAnimatedStyle = useAnimatedStyle(() => { 175 + return { 176 + height: interpolate( 177 + scrollOffset.value, 178 + [headerHeight.value, 0, -headerHeight.value], 179 + [topBarHeight.value, headerHeight.value, headerHeight.value] 180 + ), 181 + }; 182 + }); 183 + 184 return ( 185 <View style={{ backgroundColor: theme[colorScheme!].background, height: "100%", maxHeight: "100%" }}> 186 <Animated.View 187 + onLayout={(ev) => 188 + ev.nativeEvent.layout.height > topBarHeight.get() && topBarHeight.set(ev.nativeEvent.layout.height) 189 + } 190 style={[ 191 { 192 backgroundColor: theme[colorScheme!].elevation.level2, ··· 226 </Pressable> 227 <Pressable 228 onPress={() => { 229 + listRef.current!.scrollToOffset({ offset: 0, animated: true }); 230 }} 231 > 232 <Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 14 }}> ··· 238 </Pressable> 239 </Animated.View> 240 <View style={{ height: "100%" }}> 241 + <Animated.View 242 + onLayout={(ev) => 243 + ev.nativeEvent.layout.height > headerHeight.get() && headerHeight.set(ev.nativeEvent.layout.height) 244 + } 245 + style={[headerAnimatedStyle, { zIndex: 1 }]} 246 + > 247 + <Pressable 248 + style={{ 249 + position: "absolute", 250 + top: insets.top, 251 + zIndex: 1, 252 + backgroundColor: "#00000055", 253 + left: 8, 254 + borderRadius: 32, 255 + }} 256 + onPress={() => router.dismiss(1)} 257 + > 258 + <Text 259 + style={{ 260 + fontSize: 24, 261 + color: "white", 262 + textAlign: "center", 263 + textAlignVertical: "center", 264 + aspectRatio: 1, 265 + }} 266 + > 267 + 268 + </Text> 269 + </Pressable> 270 + <Image source={profileQuery.data?.banner} style={{ width: "100%", aspectRatio: 3 }} /> 271 + <View style={{ padding: 8 }}> 272 + <Text style={{ color: colorScheme === "light" ? "black" : "white", fontWeight: 700, fontSize: 24 }}> 273 + {profileQuery.data?.displayName} 274 + </Text> 275 + <Text style={{ color: colorScheme === "light" ? "black" : "white", opacity: 0.75 }}> 276 + {"@" + profileQuery.data?.handle} 277 + </Text> 278 + {(profileQuery.data?.pronouns || 279 + (labelersQuery.isSuccess && (profileQuery.data?.labels?.length ?? 0) > 0)) && ( 280 + <View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap", marginTop: 4 }}> 281 + {profileQuery.data?.pronouns && ( 282 + <Text 283 style={{ 284 + color: colorScheme === "light" ? "black" : "white", 285 + backgroundColor: theme[colorScheme!].elevation.level2, 286 + paddingVertical: 4, 287 + paddingHorizontal: 4, 288 + borderRadius: 4, 289 + alignSelf: "flex-start", 290 + fontSize: 10, 291 + fontWeight: 700, 292 }} 293 > 294 + {profileQuery.data?.pronouns} 295 + </Text> 296 + )} 297 + {labelersQuery.isSuccess && 298 + (profileQuery.data?.labels ?? []) 299 + .filter((val) => val.src !== val.uri.slice(5).split("/")[0]) 300 + .map((val) => { 301 + const labeler = labelersQuery.data.views.find((labeler) => labeler.creator.did === val.src); 302 + const labelDefs = (labeler as AppBskyLabelerDefs.LabelerViewDetailed)?.policies 303 + .labelValueDefinitions; 304 + const label = labelDefs?.find((def) => def.identifier === val.val); 305 + 306 + if (!label) return null; 307 + 308 + return ( 309 + <View 310 + key={val.src + val.val} 311 + style={{ 312 + flexDirection: "row", 313 + alignItems: "center", 314 + gap: 2, 315 + backgroundColor: theme[colorScheme!].elevation.level2, 316 + paddingVertical: 4, 317 + paddingHorizontal: 4, 318 + borderRadius: 4, 319 + }} 320 + > 321 + <Image style={{ width: 12, height: 12, borderRadius: 16 }} source={labeler?.creator.avatar} /> 322 <Text 323 style={{ 324 color: colorScheme === "light" ? "black" : "white", 325 fontSize: 10, 326 fontWeight: 700, 327 }} 328 > 329 + {label?.locales.find((lc) => lc.lang === "en")?.name ?? val.val} 330 </Text> 331 + </View> 332 + ); 333 + }) 334 + .filter((val) => val)} 335 + </View> 336 + )} 337 + </View> 338 + <View style={{ flexDirection: "row" }}> 339 + <Pressable 340 + style={{ padding: 8 }} 341 + onPress={() => { 342 + setFeedFilter("posts_and_author_threads"); 343 + }} 344 + > 345 + <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Posts</Text> 346 + </Pressable> 347 348 + <Pressable 349 + style={{ padding: 8 }} 350 + onPress={() => { 351 + setFeedFilter("posts_with_replies"); 352 + }} 353 + > 354 + <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Replies</Text> 355 + </Pressable> 356 357 + <Pressable 358 + style={{ padding: 8 }} 359 + onPress={() => { 360 + setFeedFilter("posts_with_media"); 361 + }} 362 + > 363 + <Text style={{ fontWeight: 700, color: colorScheme === "light" ? "black" : "white" }}>Media</Text> 364 + </Pressable> 365 + </View> 366 + </Animated.View> 367 + <Animated.View style={{ position: "absolute", height: "100%", top: 0, left: 0, width: "100%" }}> 368 + <AnimatedLegendList 369 + recycleItems 370 + ref={listRef} 371 + onScroll={(ev) => { 372 + scrollOffset.set(ev.nativeEvent.contentOffset.y); 373 + }} 374 + scrollEventThrottle={16} 375 + decelerationRate={"normal"} 376 + snapToEnd={false} 377 + snapToStart={false} 378 + pagingEnabled={false} 379 + data={(authorFeedQuery.data?.pages ?? []).flatMap((val) => val.feed)} 380 + onEndReached={() => authorFeedQuery.fetchNextPage()} 381 + refreshControl={<RefreshControl progressViewOffset={300} refreshing={authorFeedQuery.isRefetching} />} 382 + ListHeaderComponent={<Animated.View style={[listAnimatedStyle]} />} 383 + ListFooterComponent={<View />} 384 + ListFooterComponentStyle={{ height: 64 }} 385 + onRefresh={() => authorFeedQuery.refetch()} 386 + renderItem={({ item }) => { 387 + return <Post post={item.post} />; 388 + }} 389 + /> 390 + </Animated.View> 391 </View> 392 </View> 393 );