this repo has no description
0
fork

Configure Feed

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

scroll on tab press! + fix profile screen header behaviour

+170 -103
+1 -1
apps/expo/app.config.ts
··· 4 4 name: "Graysky", 5 5 slug: "graysky", 6 6 scheme: "graysky", 7 - version: "0.0.4", 7 + version: "0.0.5", 8 8 owner: "mozzius", 9 9 orientation: "portrait", 10 10 icon: "./assets/icon.png",
+2 -1
apps/expo/package.json
··· 1 1 { 2 2 "name": "@graysky/app", 3 - "version": "0.0.4", 3 + "version": "0.0.5", 4 4 "main": "index.tsx", 5 5 "scripts": { 6 6 "clean": "git clean -xdf .expo .turbo node_modules", ··· 17 17 "@expo/metro-config": "^0.7.1", 18 18 "@react-native-async-storage/async-storage": "1.17.11", 19 19 "@react-native-community/netinfo": "9.3.7", 20 + "@react-navigation/elements": "^1.3.17", 20 21 "@shopify/flash-list": "1.4.0", 21 22 "@tanstack/react-query": "^4.29.3", 22 23 "@trpc/client": "^10.21.1",
+6
apps/expo/src/app/(tabs)/notifications.tsx
··· 22 22 import { FeedPost } from "../../components/feed-post"; 23 23 import { RichText } from "../../components/rich-text"; 24 24 import { useAuthedAgent } from "../../lib/agent"; 25 + import { useTabPressScrollRef } from "../../lib/hooks"; 25 26 import { queryClient } from "../../lib/query-client"; 26 27 import { assert } from "../../lib/utils/assert"; 27 28 import { cx } from "../../lib/utils/cx"; ··· 99 100 return grouped; 100 101 }, [notifications.data]); 101 102 103 + const ref = useTabPressScrollRef(() => { 104 + notifications.refetch(); 105 + }); 106 + 102 107 switch (notifications.status) { 103 108 case "loading": 104 109 return ( ··· 127 132 <> 128 133 <Stack.Screen options={{ headerShown: true }} /> 129 134 <FlashList 135 + ref={ref} 130 136 data={data} 131 137 renderItem={({ item }) => <Notification {...item} />} 132 138 estimatedItemSize={105}
+1 -1
apps/expo/src/app/(tabs)/profile.tsx
··· 4 4 export default function ProfilePage() { 5 5 const agent = useAuthedAgent(); 6 6 7 - return <ProfileView handle={agent.session.handle} />; 7 + return <ProfileView handle={agent.session.handle} header={false} />; 8 8 }
+4 -6
apps/expo/src/app/(tabs)/timeline.tsx
··· 1 1 import { useMemo, useRef, useState } from "react"; 2 2 import { ActivityIndicator, Text, View } from "react-native"; 3 - import { Tabs as NavigationTabs, Stack } from "expo-router"; 3 + import { Stack } from "expo-router"; 4 4 import { AppBskyFeedDefs } from "@atproto/api"; 5 5 import { FlashList } from "@shopify/flash-list"; 6 6 import { useInfiniteQuery } from "@tanstack/react-query"; ··· 9 9 import { FeedPost } from "../../components/feed-post"; 10 10 import { Tab, Tabs } from "../../components/tabs"; 11 11 import { useAuthedAgent } from "../../lib/agent"; 12 + import { useTabPressScroll } from "../../lib/hooks"; 12 13 import { assert } from "../../lib/utils/assert"; 13 14 import { useUserRefresh } from "../../lib/utils/query"; 14 15 ··· 88 89 .flat(); 89 90 }, [timeline]); 90 91 92 + useTabPressScroll(ref); 93 + 91 94 const header = ( 92 95 <> 93 96 <Stack.Screen options={{ headerShown: true }} /> 94 - <NavigationTabs.Screen 95 - listeners={(...args: any[]) => { 96 - console.log("listeners", args); 97 - }} 98 - /> 99 97 <Tabs> 100 98 <Tab 101 99 text="Following"
+6 -3
apps/expo/src/app/profile/[handle]/post/[id].tsx
··· 8 8 import { FeedPost } from "../../../../components/feed-post"; 9 9 import { Post } from "../../../../components/post"; 10 10 import { useAuthedAgent } from "../../../../lib/agent"; 11 + import { useTabPressScroll } from "../../../../lib/hooks"; 11 12 import { assert } from "../../../../lib/utils/assert"; 12 13 import { useUserRefresh } from "../../../../lib/utils/query"; 13 14 ··· 112 113 113 114 // hacky but needed until https://github.com/Shopify/flash-list/issues/671 is fixed 114 115 useEffect(() => { 115 - if (thread.data && !hasScrolled.current) { 116 + if (thread.isSuccess && !hasScrolled.current) { 116 117 const index = thread.data.index; 117 118 hasScrolled.current = true; 118 119 setTimeout(() => { ··· 120 121 animated: true, 121 122 index, 122 123 }); 123 - }, 50); 124 + }, 500); 124 125 } 125 - }, [thread.data]); 126 + }, [thread.isSuccess]); 127 + 128 + useTabPressScroll(ref); 126 129 127 130 switch (thread.status) { 128 131 case "loading":
+10 -10
apps/expo/src/components/feed-post.tsx
··· 62 62 {/* left col */} 63 63 <View className="flex flex-col items-center px-2"> 64 64 <Link href={profileHref} asChild> 65 - <TouchableOpacity> 65 + <Pressable> 66 66 {item.post.author.avatar ? ( 67 67 <Image 68 68 source={{ uri: item.post.author.avatar }} ··· 74 74 <User size={32} color="#1C1C1E" /> 75 75 </View> 76 76 )} 77 - </TouchableOpacity> 77 + </Pressable> 78 78 </Link> 79 79 <Link href={postHref} asChild> 80 - <TouchableOpacity className="w-full grow items-center"> 80 + <Pressable className="w-full grow items-center"> 81 81 {hasReply && <View className="w-1 grow bg-neutral-200" />} 82 - </TouchableOpacity> 82 + </Pressable> 83 83 </Link> 84 84 </View> 85 85 {/* right col */} 86 86 <View className="flex-1 pb-2.5 pl-1 pr-2"> 87 87 <Link href={profileHref} asChild> 88 - <TouchableOpacity className="flex-row items-center"> 88 + <Pressable className="flex-row items-center"> 89 89 <Text numberOfLines={1} className="max-w-[85%] text-base"> 90 90 <Text className="font-semibold"> 91 91 {item.post.author.displayName} ··· 99 99 {" · "} 100 100 {timeSince(new Date(item.post.indexedAt))} 101 101 </Text> 102 - </TouchableOpacity> 102 + </Pressable> 103 103 </Link> 104 104 {/* inline "replying to so-and-so" */} 105 105 {displayInlineParent && ··· 110 110 }/post/${item.reply.parent.uri.split("/").pop()}`} 111 111 asChild 112 112 > 113 - <TouchableOpacity className="flex-row items-center"> 113 + <Pressable className="flex-row items-center"> 114 114 <MessageCircle size={12} color="#737373" /> 115 115 <Text className="ml-1 text-neutral-500"> 116 116 replying to{" "} 117 117 {item.reply.parent.author.displayName ?? 118 118 `@${item.reply.parent.author.handle}`} 119 119 </Text> 120 - </TouchableOpacity> 120 + </Pressable> 121 121 </Link> 122 122 ) : ( 123 123 !!item.post.record.reply && ( ··· 220 220 href={`/profile/${data.author.handle}/post/${data.uri.split("/").pop()}`} 221 221 asChild 222 222 > 223 - <TouchableOpacity className="flex-row items-center"> 223 + <Pressable className="flex-row items-center"> 224 224 <MessageCircle size={12} color="#737373" /> 225 225 <Text className="ml-1 text-neutral-500"> 226 226 replying to {data.author.displayName ?? `@${data.author.handle}`} 227 227 </Text> 228 - </TouchableOpacity> 228 + </Pressable> 229 229 </Link> 230 230 ); 231 231 };
+15 -3
apps/expo/src/components/profile-info.tsx
··· 1 - import { Button, Image, Text, View } from "react-native"; 1 + import { Button, Image, Text, TouchableOpacity, View } from "react-native"; 2 + import { useRouter } from "expo-router"; 2 3 import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 4 import { useMutation } from "@tanstack/react-query"; 5 + import { ChevronLeft } from "lucide-react-native"; 4 6 5 7 import { useAuthedAgent } from "../lib/agent"; 6 8 import { queryClient } from "../lib/query-client"; 7 9 8 10 interface Props { 9 11 profile: ProfileViewDetailed; 12 + backButton?: boolean; 10 13 } 11 14 12 - export const ProfileInfo = ({ profile }: Props) => { 15 + export const ProfileInfo = ({ profile, backButton }: Props) => { 13 16 const agent = useAuthedAgent(); 17 + const router = useRouter(); 14 18 15 19 const toggleFollow = useMutation({ 16 20 mutationKey: ["follow", profile.did], ··· 26 30 }); 27 31 28 32 return ( 29 - <View> 33 + <View className="relative"> 30 34 <Image 31 35 source={{ uri: profile.banner }} 32 36 className="h-32 w-full" 33 37 alt="banner image" 34 38 /> 39 + {backButton && ( 40 + <TouchableOpacity 41 + onPress={() => router.back()} 42 + className="absolute left-4 top-4 items-center justify-center rounded-full bg-black/60 p-2" 43 + > 44 + <ChevronLeft size={24} color="white" /> 45 + </TouchableOpacity> 46 + )} 35 47 <View className="relative border-b border-b-neutral-200 bg-white px-4 pb-4"> 36 48 <View className="h-10 flex-row items-center justify-end"> 37 49 <View className="absolute -top-11 left-0 rounded-full border-4 border-white">
+71 -63
apps/expo/src/components/profile-view.tsx
··· 1 1 import { useMemo, useRef, useState } from "react"; 2 2 import { ActivityIndicator, Text, View } from "react-native"; 3 - import { SafeAreaView } from "react-native-safe-area-context"; 3 + import { 4 + SafeAreaView, 5 + useSafeAreaInsets, 6 + } from "react-native-safe-area-context"; 4 7 import { Stack } from "expo-router"; 5 8 import { AppBskyFeedDefs, AppBskyFeedLike } from "@atproto/api"; 9 + import { useHeaderHeight } from "@react-navigation/elements"; 6 10 import { FlashList } from "@shopify/flash-list"; 7 11 import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; 8 12 9 13 import { useAuthedAgent } from "../lib/agent"; 14 + import { useTabPressScroll } from "../lib/hooks"; 10 15 import { assert } from "../lib/utils/assert"; 11 16 import { useUserRefresh } from "../lib/utils/query"; 12 17 import { FeedPost } from "./feed-post"; ··· 15 20 16 21 interface Props { 17 22 handle: string; 23 + header?: boolean; 18 24 } 19 25 20 - export const ProfileView = ({ handle }: Props) => { 26 + export const ProfileView = ({ handle, header = true }: Props) => { 21 27 const [mode, setMode] = useState<"posts" | "replies" | "likes">("posts"); 22 28 const [atTop, setAtTop] = useState(true); 23 29 const agent = useAuthedAgent(); 24 30 const ref = useRef<FlashList<any>>(null); 31 + const headerHeight = useHeaderHeight(); 32 + const { top } = useSafeAreaInsets(); 33 + 34 + const tabOffset = headerHeight - top; 25 35 26 36 const profile = useQuery(["profile", handle], async () => { 27 37 const profile = await agent.getProfile({ ··· 127 137 .flat(); 128 138 }, [timeline, mode]); 129 139 140 + useTabPressScroll(ref); 141 + 142 + const tabs = (offset: boolean) => ( 143 + <Tabs 144 + style={{ 145 + marginTop: offset ? tabOffset : 0, 146 + }} 147 + > 148 + <Tab 149 + text="Posts" 150 + active={mode === "posts"} 151 + onPress={() => 152 + mode === "posts" 153 + ? ref.current?.scrollToIndex({ 154 + index: 0, 155 + animated: true, 156 + }) 157 + : setMode("posts") 158 + } 159 + /> 160 + <Tab 161 + text="Posts & Replies" 162 + active={mode === "replies"} 163 + onPress={() => 164 + mode === "replies" 165 + ? ref.current?.scrollToIndex({ 166 + index: 0, 167 + animated: true, 168 + }) 169 + : setMode("replies") 170 + } 171 + /> 172 + <Tab 173 + text="Likes" 174 + active={mode === "likes"} 175 + onPress={() => 176 + mode === "likes" 177 + ? ref.current?.scrollToIndex({ 178 + index: 0, 179 + animated: true, 180 + }) 181 + : setMode("likes") 182 + } 183 + /> 184 + </Tabs> 185 + ); 186 + 130 187 switch (profile.status) { 131 188 case "loading": 132 189 return ( 133 190 <View className="flex-1 items-center justify-center"> 134 191 <Stack.Screen 135 192 options={{ 136 - headerTitle: "", 137 - headerTransparent: true, 138 - headerStyle: { 139 - backgroundColor: atTop ? "transparent" : undefined, 140 - }, 193 + headerShown: false, 141 194 }} 142 195 /> 143 196 <ActivityIndicator /> ··· 148 201 <View className="flex-1 items-center justify-center p-4"> 149 202 <Stack.Screen 150 203 options={{ 151 - headerTitle: "", 152 - headerTransparent: true, 153 - headerStyle: { 154 - backgroundColor: atTop ? "transparent" : undefined, 155 - }, 204 + headerShown: true, 205 + headerTitle: "Profile not found", 156 206 }} 157 207 /> 158 208 <Text className="text-center text-xl"> ··· 162 212 ); 163 213 case "success": 164 214 return ( 165 - <SafeAreaView className="flex-1" edges={["top", "left", "right"]}> 215 + <SafeAreaView className="flex-1" edges={["top"]}> 166 216 <Stack.Screen 167 217 options={{ 168 218 headerTransparent: true, 169 219 headerTitle: "", 170 - ...(!atTop 171 - ? { 172 - headerBlurEffect: "systemThinMaterialLight", 173 - } 174 - : { 175 - headerStyle: { 176 - backgroundColor: atTop ? "transparent" : undefined, 177 - }, 178 - }), 220 + headerBlurEffect: "systemThinMaterialLight", 221 + headerShown: header && !atTop, 179 222 }} 180 223 /> 181 224 <FlashList 182 225 ref={ref} 183 226 data={[null, ...data]} 184 - renderItem={({ item, index }) => 227 + renderItem={({ item, index, target }) => 185 228 item === null ? ( 186 - <Tabs> 187 - <Tab 188 - text="Posts" 189 - active={mode === "posts"} 190 - onPress={() => 191 - mode === "posts" 192 - ? ref.current?.scrollToIndex({ 193 - index: 0, 194 - animated: true, 195 - }) 196 - : setMode("posts") 197 - } 198 - /> 199 - <Tab 200 - text="Posts & Replies" 201 - active={mode === "replies"} 202 - onPress={() => 203 - mode === "replies" 204 - ? ref.current?.scrollToIndex({ 205 - index: 0, 206 - animated: true, 207 - }) 208 - : setMode("replies") 209 - } 210 - /> 211 - <Tab 212 - text="Likes" 213 - active={mode === "likes"} 214 - onPress={() => 215 - mode === "likes" 216 - ? ref.current?.scrollToIndex({ 217 - index: 0, 218 - animated: true, 219 - }) 220 - : setMode("likes") 221 - } 222 - /> 223 - </Tabs> 229 + tabs(target === "StickyHeader" && header) 224 230 ) : ( 225 231 <FeedPost 226 232 {...item} ··· 229 235 /> 230 236 ) 231 237 } 232 - stickyHeaderIndices={[0]} 238 + stickyHeaderIndices={atTop ? [] : [0]} 233 239 onEndReachedThreshold={0.5} 234 240 onEndReached={() => void timeline.fetchNextPage()} 235 241 onRefresh={handleRefresh} ··· 239 245 const { contentOffset } = evt.nativeEvent; 240 246 setAtTop(contentOffset.y <= 30); 241 247 }} 242 - ListHeaderComponent={<ProfileInfo profile={profile.data} />} 248 + ListHeaderComponent={ 249 + <ProfileInfo profile={profile.data} backButton={header} /> 250 + } 243 251 ListFooterComponent={ 244 252 timeline.isFetching ? ( 245 253 <View className="w-full items-center py-8">
+16 -7
apps/expo/src/components/tabs.tsx
··· 1 - import { Text, TouchableOpacity, View } from "react-native"; 1 + import { 2 + Text, 3 + TouchableOpacity, 4 + View, 5 + type StyleProp, 6 + type ViewStyle, 7 + } from "react-native"; 2 8 3 9 import { cx } from "../lib/utils/cx"; 4 10 5 - export const Tabs = ({ 6 - children, 7 - className, 8 - }: React.PropsWithChildren<{ className?: string }>) => { 11 + interface TabsProps extends React.PropsWithChildren { 12 + className?: string; 13 + style?: StyleProp<ViewStyle>; 14 + } 15 + 16 + export const Tabs = ({ children, className, style }: TabsProps) => { 9 17 return ( 10 18 <View 11 19 className={cx( 12 20 "w-full flex-row border-b border-neutral-200 bg-white", 13 21 className, 14 22 )} 23 + style={style} 15 24 > 16 25 {children} 17 26 </View> 18 27 ); 19 28 }; 20 29 21 - interface Props { 30 + interface TabProps { 22 31 active: boolean; 23 32 onPress: () => void; 24 33 text: string; 25 34 } 26 35 27 - export const Tab = ({ active, onPress, text }: Props) => { 36 + export const Tab = ({ active, onPress, text }: TabProps) => { 28 37 return ( 29 38 <TouchableOpacity 30 39 onPress={onPress}
+33 -1
apps/expo/src/lib/hooks.ts
··· 1 - import { useRef, useState } from "react"; 1 + import { useEffect, useRef, useState } from "react"; 2 2 import * as Haptics from "expo-haptics"; 3 + import { useNavigation } from "expo-router"; 3 4 import { type AppBskyFeedDefs } from "@atproto/api"; 5 + import { type FlashList } from "@shopify/flash-list"; 4 6 import { useMutation } from "@tanstack/react-query"; 5 7 6 8 import { useAuthedAgent } from "./agent"; ··· 101 103 toggleRepost, 102 104 }; 103 105 }; 106 + 107 + export const useTabPressScroll = ( 108 + ref: React.RefObject<FlashList<any>>, 109 + callback = () => {}, 110 + ) => { 111 + const navigation = useNavigation(); 112 + 113 + useEffect(() => { 114 + // @ts-expect-error doesn't know what kind of navigator it is 115 + const unsub = navigation.addListener("tabPress", () => { 116 + if (navigation.isFocused()) { 117 + ref.current?.scrollToOffset({ 118 + offset: 0, 119 + animated: true, 120 + }); 121 + callback(); 122 + } 123 + }); 124 + 125 + return unsub; 126 + }, [callback]); 127 + }; 128 + 129 + export const useTabPressScrollRef = (callback = () => {}) => { 130 + const ref = useRef<FlashList<any>>(null); 131 + 132 + useTabPressScroll(ref, callback); 133 + 134 + return ref; 135 + };
+2 -7
apps/nextjs/src/pages/index.tsx
··· 1 1 import Head from "next/head"; 2 2 3 - import { api } from "~/utils/api"; 4 - 5 3 export default function HomePage() { 6 - const query = api.useless.ping.useQuery(); 7 - 8 4 return ( 9 5 <div> 10 6 <Head> 11 - <title>Home</title> 7 + <title>Graysky</title> 12 8 </Head> 13 9 14 10 <main> 15 - <h1>Home</h1> 16 - <p>{query.data}</p> 11 + <h1>Graysky</h1> 17 12 </main> 18 13 </div> 19 14 );
+3
pnpm-lock.yaml
··· 46 46 '@react-native-community/netinfo': 47 47 specifier: 9.3.7 48 48 version: 9.3.7(react-native@0.71.6) 49 + '@react-navigation/elements': 50 + specifier: ^1.3.17 51 + version: 1.3.17(@react-navigation/native@6.1.6)(react-native-safe-area-context@4.5.0)(react-native@0.71.6)(react@18.2.0) 49 52 '@shopify/flash-list': 50 53 specifier: 1.4.0 51 54 version: 1.4.0(@babel/runtime@7.21.0)(react-native@0.71.6)(react@18.2.0)