diff --git a/mobile/app.json b/mobile/app.json index 5e2193e..4f85f76 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -23,6 +23,14 @@ }, "web": { "favicon": "./assets/favicon.png" - } + }, + "plugins": [ + [ + "expo-secure-store", + { + "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." + } + ] + ] } } diff --git a/mobile/bun.lockb b/mobile/bun.lockb index af4463f..a1058ef 100755 Binary files a/mobile/bun.lockb and b/mobile/bun.lockb differ diff --git a/mobile/package.json b/mobile/package.json index c93d641..8c2933d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -19,6 +19,7 @@ "@tanstack/react-query": "^5.17.19", "date-fns": "^3.3.1", "expo": "50.0.3", + "expo-secure-store": "^12.8.1", "expo-status-bar": "1.11.1", "nativewind": "^2.0.11", "react": "18.2.0", diff --git a/mobile/src/contexts/AuthProvider.tsx b/mobile/src/contexts/AuthProvider.tsx index 5c4fc40..ba836d2 100644 --- a/mobile/src/contexts/AuthProvider.tsx +++ b/mobile/src/contexts/AuthProvider.tsx @@ -1,18 +1,25 @@ import { AuthKitProvider, useSignIn } from '@farcaster/auth-kit'; +import { FullscreenLoader } from '@mobile/components/loader/FullscreenLoader'; +import { useFetchProfile } from '@mobile/hooks/data/profile'; +import { User } from '@shared/types/models'; +import * as SecureStore from 'expo-secure-store'; import { createContext, ReactNode, useCallback, useContext, - useState, + useEffect, + useReducer, } from 'react'; import { Linking } from 'react-native'; const AuthContext = createContext<{ + currentUser: User | undefined; isSignedIn: boolean; signIn: () => Promise; token: string | undefined; }>({ + currentUser: undefined, isSignedIn: false, signIn: async () => { throw new Error( @@ -22,12 +29,50 @@ const AuthContext = createContext<{ token: undefined, }); +type Session = { + fid: string; + token: string; +}; + +type State = { + currentUser: User | undefined; + isInitialized: boolean; + session: Session | undefined; +}; + +type Action = + | { type: 'initWitUser'; session: Session; user: User } + | { type: 'initWithoutUser' } + | { type: 'signIn' } + | { type: 'signOut' }; + +const initialState: State = { + currentUser: undefined, + isInitialized: false, + session: undefined, +}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'initWitUser': + return { isInitialized: true, session: action.session }; + case 'initWithoutUser': + return { isInitialized: true, session: undefined }; + case 'signIn': + return { ...state }; + case 'signOut': + return { ...state }; + } + return state; +} + type AuthProviderProps = { children: ReactNode; }; function AuthProviderContent({ children }: AuthProviderProps) { - const [token, setToken] = useState(); + const [state, dispatch] = useReducer(reducer, initialState); + const fetchProfile = useFetchProfile(); const { connect, @@ -38,16 +83,59 @@ function AuthProviderContent({ children }: AuthProviderProps) { // setToken(res); }, }); + const signIn = useCallback(async () => { - // if (url) { - await connect(); - await authKitSignIn(); // Starts polling - Linking.openURL(url); - // } + if (!url) { + throw new Error('Expected authkit to provide url'); + } + + if (url) { + await connect(); + await authKitSignIn(); // Starts polling + Linking.openURL(url); + } }, [authKitSignIn, connect, url]); + const init = useCallback(async () => { + const persistedSessionJson = await SecureStore.getItemAsync('session'); + + if (persistedSessionJson) { + try { + const persistedSession: Session = JSON.parse(persistedSessionJson); + const { profile: user } = await fetchProfile({ + fid: persistedSession.fid, + }); + + return dispatch({ + type: 'initWitUser', + session: persistedSession, + user, + }); + } catch (error) { + console.error(error); + } + } + + dispatch({ type: 'initWithoutUser' }); + }, [fetchProfile]); + + useEffect(() => { + init(); + }, [init]); + + if (!state.isInitialized) { + return ; + } + return ( - + {children} ); diff --git a/mobile/src/hooks/data/feed.ts b/mobile/src/hooks/data/feed.ts new file mode 100644 index 0000000..ae8174d --- /dev/null +++ b/mobile/src/hooks/data/feed.ts @@ -0,0 +1,31 @@ +import { baseApiUrl } from '@mobile/constants/api'; +import { FeedApiResponse } from '@shared/types/api'; +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; + +function buildFeedKey({ fid }: { fid: string }) { + return ['feed', fid]; +} + +function buildFeedFetcher({ fid }: { fid: string }) { + return async (): Promise => { + const res = await fetch(`${baseApiUrl}/feed/${fid}`); + return res.json(); + }; +} + +export function useFeed({ fid }: { fid: string }) { + return useSuspenseQuery({ + queryKey: buildFeedKey({ fid }), + queryFn: buildFeedFetcher({ fid }), + }).data; +} + +export function useFetchFeed() { + const queryClient = useQueryClient(); + + return async ({ fid }: { fid: string }) => { + const data = buildFeedFetcher({ fid })(); + queryClient.setQueryData(buildFeedKey({ fid }), data); + return data; + }; +} diff --git a/mobile/src/hooks/data/profile.ts b/mobile/src/hooks/data/profile.ts new file mode 100644 index 0000000..b8bd609 --- /dev/null +++ b/mobile/src/hooks/data/profile.ts @@ -0,0 +1,31 @@ +import { baseApiUrl } from '@mobile/constants/api'; +import { ProfileApiResponse } from '@shared/types/api'; +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; + +function buildProfileKey({ fid }: { fid: string }) { + return ['profile', fid]; +} + +function buildProfileFetcher({ fid }: { fid: string }) { + return async (): Promise => { + const res = await fetch(`${baseApiUrl}/profiles/${fid}`); + return res.json(); + }; +} + +export function useProfile({ fid }: { fid: string }) { + return useSuspenseQuery({ + queryKey: buildProfileKey({ fid }), + queryFn: buildProfileFetcher({ fid }), + }).data; +} + +export function useFetchProfile() { + const queryClient = useQueryClient(); + + return async ({ fid }: { fid: string }) => { + const data = buildProfileFetcher({ fid })(); + queryClient.setQueryData(buildProfileKey({ fid }), data); + return data; + }; +} diff --git a/mobile/src/hooks/data/profileCasts.ts b/mobile/src/hooks/data/profileCasts.ts new file mode 100644 index 0000000..ac992ae --- /dev/null +++ b/mobile/src/hooks/data/profileCasts.ts @@ -0,0 +1,31 @@ +import { baseApiUrl } from '@mobile/constants/api'; +import { ProfileCastsApiResponse } from '@shared/types/api'; +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; + +function buildProfileCastsKey({ fid }: { fid: string }) { + return ['profileCasts', fid]; +} + +function buildProfileCastsFetcher({ fid }: { fid: string }) { + return async (): Promise => { + const res = await fetch(`${baseApiUrl}/profiles/${fid}/casts`); + return res.json(); + }; +} + +export function useProfileCasts({ fid }: { fid: string }) { + return useSuspenseQuery({ + queryKey: buildProfileCastsKey({ fid }), + queryFn: buildProfileCastsFetcher({ fid }), + }).data; +} + +export function useFetchProfileCasts() { + const queryClient = useQueryClient(); + + return async ({ fid }: { fid: string }) => { + const data = buildProfileCastsFetcher({ fid })(); + queryClient.setQueryData(buildProfileCastsKey({ fid }), data); + return data; + }; +} diff --git a/mobile/src/screens/FeedScreen.tsx b/mobile/src/screens/FeedScreen.tsx index fd90db3..f57e1de 100644 --- a/mobile/src/screens/FeedScreen.tsx +++ b/mobile/src/screens/FeedScreen.tsx @@ -1,41 +1,27 @@ import { Cast } from '@mobile/components/feed/Cast'; -import { baseApiUrl } from '@mobile/constants/api'; +import { useFeed, useFetchFeed } from '@mobile/hooks/data/feed'; import { buildScreen } from '@mobile/utils/buildScreen'; -import { FeedApiResponse } from '@shared/types/api'; import { Cast as CastType } from '@shared/types/models'; import { FlashList } from '@shopify/flash-list'; -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; -const feedKey = ['feed']; - -async function fetchFeed(): Promise { - const res = await fetch(`${baseApiUrl}/feed?fid=145`); - return res.json(); -} - function renderItem({ item }: { item: CastType }) { return ; } export const FeedScreen = buildScreen(() => { - const queryClient = useQueryClient(); - + const { feed } = useFeed({ fid }); + const fetchFeed = useFetchFeed(); const [isRefreshing, setIsRefreshing] = useState(false); - const { feed } = useSuspenseQuery({ - queryKey: feedKey, - queryFn: fetchFeed, - }).data; - const onRefresh = useCallback(async () => { try { setIsRefreshing(true); - queryClient.setQueryData(feedKey, await fetchFeed()); + await fetchFeed({ fid }); } finally { setIsRefreshing(false); } - }, [queryClient]); + }, [fetchFeed]); return ( { const { fid } = useRoute().params as RootParamList['Profile']; - const profileKey = useMemo(() => ['profile', fid], [fid]); - const profileCastsKey = useMemo(() => ['profileCasts', fid], [fid]); + const { profile } = useProfile({ fid }); + const { casts } = useProfileCasts({ fid }); + const fetchProfileCasts = useFetchProfileCasts(); - const [ - { - data: { profile }, - }, - { - data: { casts }, - }, - ] = useSuspenseQueries({ - queries: [ - { - queryKey: profileKey, - queryFn: (): Promise => - fetch(`${baseApiUrl}/users/${fid}`).then((res) => res.json()), - }, - { - queryKey: profileCastsKey, - queryFn: (): Promise => - fetch(`${baseApiUrl}/users/${fid}/casts`).then((res) => res.json()), - }, - ], - }); + const [isRefreshing, setIsRefreshing] = useState(false); + + const onRefresh = useCallback(async () => { + try { + setIsRefreshing(true); + await fetchProfileCasts({ fid }); + } finally { + setIsRefreshing(false); + } + }, [fetchProfileCasts, fid]); return ( @@ -68,6 +60,8 @@ export const ProfileScreen = buildScreen(() => { data={casts} renderItem={renderItem} estimatedItemSize={190} + refreshing={isRefreshing} + onRefresh={onRefresh} /> diff --git a/web/src/app/api/users/[fid]/casts/route.ts b/web/src/app/api/profiles/[fid]/casts/route.ts similarity index 100% rename from web/src/app/api/users/[fid]/casts/route.ts rename to web/src/app/api/profiles/[fid]/casts/route.ts diff --git a/web/src/app/api/users/[fid]/route.ts b/web/src/app/api/profiles/[fid]/route.ts similarity index 100% rename from web/src/app/api/users/[fid]/route.ts rename to web/src/app/api/profiles/[fid]/route.ts diff --git a/web/src/app/casts/page.tsx b/web/src/app/casts/page.tsx index f723c33..9b027e3 100644 --- a/web/src/app/casts/page.tsx +++ b/web/src/app/casts/page.tsx @@ -1,17 +1,12 @@ -import { authOptions } from "@app/api/auth/[...nextauth]/route"; -import { LandingPage } from "@components/landing/LandingPage"; -import { ProfilePage } from "@components/profile/ProfilePage"; -import { getServerSession } from "next-auth"; +import { LandingPage } from '@components/landing/LandingPage'; +import { ProfilePage } from '@components/profile/ProfilePage'; +import { getCurrentUser } from '@lib/auth/getCurrentUser'; export default async function Feed() { - const session = await getServerSession(authOptions); + const user = await getCurrentUser(); - if (session) { - const { - user: { fid }, - } = session; - - return ; + if (user) { + return ; } return ; diff --git a/web/src/app/feed/page.tsx b/web/src/app/feed/page.tsx deleted file mode 100644 index c2d792e..0000000 --- a/web/src/app/feed/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { authOptions } from "@app/api/auth/[...nextauth]/route"; -import { FeedPage } from "@components/feed/FeedPage"; -import { LandingPage } from "@components/landing/LandingPage"; -import { getServerSession } from "next-auth"; - -export default async function Feed() { - const session = await getServerSession(authOptions); - - if (session) { - const { - user: { fid }, - } = session; - - return ; - } - - return ; -} diff --git a/web/src/components/profile/ProfilePage.tsx b/web/src/components/profile/ProfilePage.tsx index 5ad6a10..4765bc9 100644 --- a/web/src/components/profile/ProfilePage.tsx +++ b/web/src/components/profile/ProfilePage.tsx @@ -1,14 +1,14 @@ -import { Feed } from "@components/feed/Feed"; -import { Nav } from "@components/nav/Nav"; -import { Profile } from "@components/profile/Profile"; -import { getCasts } from "@lib/services/casts"; -import { getProfile } from "@lib/services/user"; +import { Feed } from '@components/feed/Feed'; +import { Nav } from '@components/nav/Nav'; +import { Profile } from '@components/profile/Profile'; +import { getCasts } from '@lib/services/casts'; +import { getProfile } from '@lib/services/user'; -type ProfilePageProps = { +type CastsPageProps = { fid: string; }; -export async function ProfilePage({ fid }: ProfilePageProps) { +export async function CastsPage({ fid }: CastsPageProps) { const casts = await getCasts({ fid }); const user = await getProfile({ fid }); diff --git a/web/src/contexts/AuthProvider.tsx b/web/src/contexts/AuthProvider.tsx deleted file mode 100644 index a97c821..0000000 --- a/web/src/contexts/AuthProvider.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { AuthKitProvider, useSignIn } from '@farcaster/auth-kit'; -import { - createContext, - ReactNode, - useCallback, - useContext, - useState, -} from 'react'; - -const AuthContext = createContext<{ - isSignedIn: boolean; - signIn: () => Promise; - token: string | undefined; -}>({ - isSignedIn: false, - signIn: async () => { - throw new Error( - 'You need to add an AuthProvider to the top of your React tree.', - ); - }, - token: undefined, -}); - -type AuthProviderProps = { - children: ReactNode; -}; - -function AuthProviderContent({ children }: AuthProviderProps) { - const [token, setToken] = useState(); - - const { - connect, - url, - signIn: authKitSignIn, - } = useSignIn({ - onSuccess: (res) => { - // setToken(res); - }, - }); - - const signIn = useCallback(async () => { - // // if (url) { - // await connect(); - // await authKitSignIn(); // Starts polling - // Linking.openURL(url); - // // } - }, []); - - return ( - - {children} - - ); -} - -export function AuthProvider(props: AuthProviderProps) { - return ( - - - - ); -} - -export function useAuth() { - return useContext(AuthContext); -}